From e736f429f9192c3925908050effd9f4cf92416d4 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 19 Sep 2018 14:58:45 +0100 Subject: [PATCH 1/9] ***SPIKE*** Initial work to paint a GUI that has the right sorts of things in the right sort of places. Now iterate. :-) --- Pipfile | 1 + Pipfile.lock | 29 ++++++++- run.py | 6 ++ securedrop_client/__init__.py | 21 +++++++ securedrop_client/app.py | 12 ++++ securedrop_client/gui/__init__.py | 0 securedrop_client/gui/main.py | 74 +++++++++++++++++++++++ securedrop_client/gui/widgets.py | 98 +++++++++++++++++++++++++++++++ tests/test_app.py | 5 +- 9 files changed, 244 insertions(+), 2 deletions(-) create mode 100755 run.py create mode 100644 securedrop_client/gui/__init__.py create mode 100644 securedrop_client/gui/main.py create mode 100644 securedrop_client/gui/widgets.py diff --git a/Pipfile b/Pipfile index bac6a11e4..5c6f14dea 100644 --- a/Pipfile +++ b/Pipfile @@ -11,6 +11,7 @@ SQLALchemy = "*" alembic = "*" securedrop-sdk = {git = "https://github.com/freedomofpress/securedrop-sdk.git"} "pathlib2" = "*" +"pyqt5" = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index fb0028778..2a6f6df40 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "26724d9042ef23868c442167f3a77bd1b05755753cbcd5781f8794e561231eae" + "sha256": "cb0973c3df383486407fa191b55f87708e8c2eaa740c55b0560986c13035b9c3" }, "pipfile-spec": 6, "requires": { @@ -44,6 +44,33 @@ "index": "pypi", "version": "==2.3.2" }, + "pyqt5": { + "hashes": [ + "sha256:700b8bb0357bf0ac312bce283449de733f5773dfc77083664be188c8e964c007", + "sha256:76d52f3627fac8bfdbc4857ce52a615cd879abd79890cde347682ff9b4b245a2", + "sha256:7d0f7c0aed9c3ef70d5856e99f30ebcfe25a58300158dd46ee544cbe1c5b53db", + "sha256:d5dc2faf0aeacd0e8b69af1dc9f1276a64020193148356bb319bdfae22b78f88" + ], + "index": "pypi", + "version": "==5.11.2" + }, + "pyqt5-sip": { + "hashes": [ + "sha256:3bcd8efae7798ce41aa7c3a052bd5ce1849f437530b8a717bae39197e780f505", + "sha256:4a3c5767d6c238d8c62d252ac59312fac8b2264a1e8a5670081d7f3545893005", + "sha256:67481d70fb0c7fb83e77b9025e15d0e78c7647c228eef934bd20ba716845a519", + "sha256:7b2e563e4e56adee00101a29913fdcc49cc714f6c4f7eb35449f493c3a88fc45", + "sha256:92a4950cba7ad7b7f67c09bdf80170ac225b38844b3a10f1271b02bace2ffc64", + "sha256:9309c10f9e648521cfe03b62f4658dad2314f81886062cb30e0ad31b337e14b0", + "sha256:9f524e60fa6113b50c48fbd869b2aef19833f3fe278097b1e7403e8f4dd5392c", + "sha256:a10f59ad65b34e183853e1387b68901f473a2041f7398fac87c4e445ab149830", + "sha256:abc2b2df469b4efb01d9dba4b804cbf0312f109ed74752dc3a37394a77d55b1f", + "sha256:c09c17009a2dd2a6317a14d3cea9b2300fdb2206cf9bc4bae0870d1919897935", + "sha256:c30c162e1430fd5a02207f1bd478e170c61d89fcca11ac6d8babb73cb33a86a8", + "sha256:f00ceceef75a2140fda737bd30847ac69b7d92fbd32b6ea7b387017e72176bd8" + ], + "version": "==4.19.12" + }, "python-dateutil": { "hashes": [ "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", diff --git a/run.py b/run.py new file mode 100755 index 000000000..ea3c6332a --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +from securedrop_client.app import run + + +if __name__ == '__main__': + run() diff --git a/securedrop_client/__init__.py b/securedrop_client/__init__.py index 80af284c1..a12708554 100644 --- a/securedrop_client/__init__.py +++ b/securedrop_client/__init__.py @@ -1 +1,22 @@ +import gettext +import locale +import os + + +# Configure locale and language. +# Define where the translation assets are to be found. +localedir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'locale')) +try: + # Use the operating system's locale. + current_locale, encoding = locale.getdefaultlocale() + # Get the language code. + language_code = current_locale[:2] +except (TypeError, ValueError): + language_code = 'en' +# DEBUG/TRANSLATE: override the language code here (e.g. to Chinese). +# language_code = 'zh' +gettext.translation('mu', localedir=localedir, + languages=[language_code], fallback=True).install() + + __version__ = '0.0.1-alpha.1' diff --git a/securedrop_client/app.py b/securedrop_client/app.py index 8843144da..b35348c45 100644 --- a/securedrop_client/app.py +++ b/securedrop_client/app.py @@ -20,8 +20,11 @@ import pathlib import os import sys +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import Qt from logging.handlers import TimedRotatingFileHandler from securedrop_client import __version__ +from securedrop_client.gui.main import Window LOG_DIR = os.path.join(str(pathlib.Path.home()), '.securedrop_client') @@ -77,3 +80,12 @@ def run(): """ configure_logging() logging.info('Starting SecureDrop Client {}'.format(__version__)) + + app = QApplication(sys.argv) + app.setApplicationName('SecureDrop Client') + app.setDesktopFileName('org.freedomofthepress.securedrop.client') + app.setApplicationVersion(__version__) + app.setAttribute(Qt.AA_UseHighDpiPixmaps) + w = Window() + w.setup() + sys.exit(app.exec_()) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py new file mode 100644 index 000000000..1f4b3879b --- /dev/null +++ b/securedrop_client/gui/main.py @@ -0,0 +1,74 @@ +import logging +from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QDesktopWidget +from securedrop_client import __version__ +from securedrop_client.gui.widgets import * + + +logger = logging.getLogger(__name__) + + +class Window(QMainWindow): + """ + Represents the application's main window that will contain the UI widgets. + + All interactions with the IU go through the object created by this class. + """ + + title = _("SecureDrop Client {}").format(__version__) + + def setup(self): + """ + Create the default start state: + + * Not logged in. + * Display current state of synced data. + + The window contains a root widget into which is placed: + + * A status bar widget at the top, containing curent user / status + information. + * A main-view widget, itself containing a list view for sources and a + place for details / message contents / forms. + """ + # self.setWindowIcon(load_icon(self.icon)) + self.widget = QWidget() + widget_layout = QVBoxLayout() + self.widget.setLayout(widget_layout) + self.tool_bar = ToolBar(self.widget) + self.main_view = MainView(self.widget) + widget_layout.addWidget(self.tool_bar, 1) + widget_layout.addWidget(self.main_view, 6) + self.setCentralWidget(self.widget) + self.main_view.source_list.update(['Benign Artichoke', 'Last Unicorn', + 'Jocular Beehive', + 'Sanitary Lemming']) + self.main_view.update_view(SourceView()) + self.show() + self.autosize_window() + + def autosize_window(self): + """ + Makes the editor 80% of the width*height of the screen and centres it. + """ + screen = QDesktopWidget().screenGeometry() + w = int(screen.width() * 0.8) + h = int(screen.height() * 0.8) + self.resize(w, h) + size = self.geometry() + self.move((screen.width() - size.width()) / 2, + (screen.height() - size.height()) / 2) + + def show_login(self, error=False): + pass + + def show_logout(self): + pass + + def update_list(self, items): + pass + + def show_source(self, source): + pass + + def update_view(self, widget): + pass diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py new file mode 100644 index 000000000..cacdd69e8 --- /dev/null +++ b/securedrop_client/gui/widgets.py @@ -0,0 +1,98 @@ +""" +Contains the main widgets used by the client to display things in the UI. +""" +import logging +from PyQt5.QtWidgets import (QListWidget, QTextEdit, QLabel, QToolBar, QAction, + QWidget, QListWidgetItem, QHBoxLayout, + QVBoxLayout, QLineEdit, QPlainTextEdit) + + +logger = logging.getLogger(__name__) + + +class ToolBar(QWidget): + """ + Represents the tool bar across the top of the user interface. + """ + + def __init__(self, parent): + super().__init__(parent) + self.setMaximumHeight(48) + layout = QHBoxLayout(self) + self.status = QLabel(_("Synchronized: 5 minutes ago.")) + self.user_state = QLabel(_("Logged in as: Testy McTestface")) + layout.addWidget(self.status) + layout.addStretch() + layout.addWidget(self.user_state) + + +class MainView(QWidget): + """ + Represents the main content of the application (containing the source list + and main context view). + """ + + def __init__(self, parent): + super().__init__(parent) + self.layout = QHBoxLayout(self) + self.setLayout(self.layout) + left_column = QWidget(parent=self) + left_layout = QVBoxLayout() + left_column.setLayout(left_layout) + filter_widget = QWidget() + filter_layout = QHBoxLayout() + filter_widget.setLayout(filter_layout) + filter_label = QLabel(_('Filter: ')) + self.filter_term = QLineEdit() + self.source_list = SourceList(left_column) + filter_layout.addWidget(filter_label) + filter_layout.addWidget(self.filter_term) + left_layout.addWidget(filter_widget) + left_layout.addWidget(self.source_list) + self.layout.addWidget(left_column, 2) + self.view_holder = QWidget() + self.layout.addWidget(self.view_holder, 6) + + def update_view(self, widget): + """ + Update the view holder to contain the referenced widget. + """ + layout = QVBoxLayout() + self.view_holder.setLayout(layout) + layout.addWidget(widget) + + +class SourceList(QListWidget): + """ + Represents the list of sources. + """ + + def __init__(self, parent): + super().__init__(parent) + + def update(self, sources): + """ + Reset and update the list with the passed in list of sources. + """ + self.clear() + for source in sources: + new_source = SourceListItem(source, self) + +class SourceListItem(QListWidgetItem): + """ + Represents a source to be listed in the user interface. + """ + + def __init__(self, source, parent): + super().__init__(parent) + self.setText(source) + #self.setIcon(load_icon(self.icon)) + +class SourceView(QPlainTextEdit): + pass + +class LoginView(QWidget): + pass + +class LogoutView(QWidget): + pass diff --git a/tests/test_app.py b/tests/test_app.py index 6478f95d3..6c11b4636 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -46,6 +46,9 @@ def test_run(): """ Ensure the expected things are configured and the application is started. """ - with mock.patch('securedrop_client.app.configure_logging') as conf_log: + with mock.patch('securedrop_client.app.configure_logging') as conf_log, \ + mock.patch('securedrop_client.app.QApplication') as mock_app, \ + mock.patch('securedrop_client.app.Window') as mock_win, \ + mock.patch('securedrop_client.app.sys') as mock_sys: run() conf_log.assert_called_once_with() From eb3b1c5eb7317e13b1048db8522b5bffcadefa9e Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 26 Sep 2018 14:29:20 +0100 Subject: [PATCH 2/9] Initial 'spike' work, tests and assets for UI scaffolding. --- securedrop_client/app.py | 18 ++- securedrop_client/gui/main.py | 82 ++++++----- securedrop_client/gui/widgets.py | 131 ++++++++++++++++-- securedrop_client/logic.py | 55 ++++++++ securedrop_client/resources/__init__.py | 65 +++++++++ securedrop_client/resources/css/sdclient.css | 3 + securedrop_client/resources/images/icon.png | Bin 0 -> 19723 bytes .../resources/images/paperclip.svg | 1 + .../resources/images/star_off.svg | 1 + .../resources/images/star_on.svg | 1 + tests/gui/__init__.py | 0 tests/gui/test_main.py | 69 +++++++++ tests/gui/test_widgets.py | 89 ++++++++++++ tests/test_app.py | 4 + tests/test_logic.py | 34 +++++ tests/test_resources.py | 55 ++++++++ 16 files changed, 552 insertions(+), 56 deletions(-) create mode 100644 securedrop_client/logic.py create mode 100644 securedrop_client/resources/__init__.py create mode 100644 securedrop_client/resources/css/sdclient.css create mode 100644 securedrop_client/resources/images/icon.png create mode 100644 securedrop_client/resources/images/paperclip.svg create mode 100644 securedrop_client/resources/images/star_off.svg create mode 100644 securedrop_client/resources/images/star_on.svg create mode 100644 tests/gui/__init__.py create mode 100644 tests/gui/test_main.py create mode 100644 tests/gui/test_widgets.py create mode 100644 tests/test_logic.py create mode 100644 tests/test_resources.py diff --git a/securedrop_client/app.py b/securedrop_client/app.py index b35348c45..0155d5548 100644 --- a/securedrop_client/app.py +++ b/securedrop_client/app.py @@ -24,7 +24,9 @@ from PyQt5.QtCore import Qt from logging.handlers import TimedRotatingFileHandler from securedrop_client import __version__ +from securedrop_client.logic import Client from securedrop_client.gui.main import Window +from securedrop_client.resources import load_icon, load_css LOG_DIR = os.path.join(str(pathlib.Path.home()), '.securedrop_client') @@ -72,11 +74,12 @@ def run(): run the application. Specific tasks include: - set up logging. - - ToDo: - - create an application object. - create a window for the app. + - create an API connection to the SecureDrop proxy. + - create a SqlAlchemy session to local storage. + - configure the client (logic) object. + - ensure the application is setup in the default safe starting state. """ configure_logging() logging.info('Starting SecureDrop Client {}'.format(__version__)) @@ -86,6 +89,11 @@ def run(): app.setDesktopFileName('org.freedomofthepress.securedrop.client') app.setApplicationVersion(__version__) app.setAttribute(Qt.AA_UseHighDpiPixmaps) - w = Window() - w.setup() + gui = Window() + app.setWindowIcon(load_icon(gui.icon)) + app.setStyleSheet(load_css('sdclient.css')) + api = None # TODO: securedrop_sdk.API + session = None # TODO: SqlAlchemy session. + client = Client(gui, api, session) + client.setup() sys.exit(app.exec_()) diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index 1f4b3879b..b60b19182 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -1,7 +1,28 @@ +""" +Contains the core UI class for the application. All interactions with the UI +go through an instance of this class. + +Copyright (C) 2018 The Freedom of the Press Foundation. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" import logging from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QDesktopWidget +from PyQt5.QtCore import Qt from securedrop_client import __version__ -from securedrop_client.gui.widgets import * +from securedrop_client.gui.widgets import ToolBar, MainView, LoginView +from securedrop_client.resources import load_icon logger = logging.getLogger(__name__) @@ -10,28 +31,26 @@ class Window(QMainWindow): """ Represents the application's main window that will contain the UI widgets. - - All interactions with the IU go through the object created by this class. + All interactions with the UI go through the object created by this class. """ - title = _("SecureDrop Client {}").format(__version__) - - def setup(self): - """ - Create the default start state: - - * Not logged in. - * Display current state of synced data. + icon = 'icon.png' - The window contains a root widget into which is placed: + def __init__(self): + """ + Create the default start state. The window contains a root widget into + which is placed: * A status bar widget at the top, containing curent user / status information. * A main-view widget, itself containing a list view for sources and a place for details / message contents / forms. """ - # self.setWindowIcon(load_icon(self.icon)) + super().__init__() + self.setWindowTitle(_("SecureDrop Client {}").format(__version__)) + self.setWindowIcon(load_icon(self.icon)) self.widget = QWidget() + self.setWindowFlags(Qt.CustomizeWindowHint) widget_layout = QVBoxLayout() self.widget.setLayout(widget_layout) self.tool_bar = ToolBar(self.widget) @@ -39,36 +58,27 @@ def setup(self): widget_layout.addWidget(self.tool_bar, 1) widget_layout.addWidget(self.main_view, 6) self.setCentralWidget(self.widget) - self.main_view.source_list.update(['Benign Artichoke', 'Last Unicorn', - 'Jocular Beehive', - 'Sanitary Lemming']) - self.main_view.update_view(SourceView()) self.show() self.autosize_window() + self.controller = None # To reference the Client (logic) instance. def autosize_window(self): """ - Makes the editor 80% of the width*height of the screen and centres it. + Ensure the application window takes up 100% of the available screen + (i.e. the whole of the virtualised desktop in Qubes dom) """ screen = QDesktopWidget().screenGeometry() - w = int(screen.width() * 0.8) - h = int(screen.height() * 0.8) - self.resize(w, h) - size = self.geometry() - self.move((screen.width() - size.width()) / 2, - (screen.height() - size.height()) / 2) + self.resize(screen.width(), screen.height()) def show_login(self, error=False): - pass - - def show_logout(self): - pass - - def update_list(self, items): - pass - - def show_source(self, source): - pass + """ + Show the login form. + """ + self.main_view.update_view(LoginView(self)) - def update_view(self, widget): - pass + def show_sources(self, sources): + """ + Update the left hand sources list in the UI with the passed in list of + sources. + """ + self.main_view.source_list.update(sources) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index cacdd69e8..9199efeac 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1,10 +1,28 @@ """ Contains the main widgets used by the client to display things in the UI. + +Copyright (C) 2018 The Freedom of the Press Foundation. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . """ import logging +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QListWidget, QTextEdit, QLabel, QToolBar, QAction, QWidget, QListWidgetItem, QHBoxLayout, - QVBoxLayout, QLineEdit, QPlainTextEdit) + QPushButton, QVBoxLayout, QLineEdit, + QPlainTextEdit) +from securedrop_client.resources import load_svg logger = logging.getLogger(__name__) @@ -14,7 +32,7 @@ class ToolBar(QWidget): """ Represents the tool bar across the top of the user interface. """ - + def __init__(self, parent): super().__init__(parent) self.setMaximumHeight(48) @@ -67,32 +85,115 @@ class SourceList(QListWidget): Represents the list of sources. """ - def __init__(self, parent): - super().__init__(parent) - def update(self, sources): """ Reset and update the list with the passed in list of sources. """ self.clear() for source in sources: - new_source = SourceListItem(source, self) + new_source = SourceWidget(source, self) + list_item = QListWidgetItem(self) + list_item.setSizeHint(new_source.sizeHint()) + self.addItem(list_item) + self.setItemWidget(list_item, new_source) + -class SourceListItem(QListWidgetItem): +class SourceWidget(QWidget): """ - Represents a source to be listed in the user interface. + Used to display summary information about a source in the list view. """ def __init__(self, source, parent): + """ + Set up the child widgets. + """ super().__init__(parent) - self.setText(source) - #self.setIcon(load_icon(self.icon)) + self.source = source + layout = QVBoxLayout() + self.setLayout(layout) + self.summary = QWidget(self) + summary_layout = QHBoxLayout() + self.summary.setLayout(summary_layout) + self.updated = QLabel() + self.attached = load_svg('paperclip.svg') + self.attached.setMaximumSize(16, 16) + self.starred = load_svg('star_on.svg') + self.starred.setMaximumSize(16, 16) + summary_layout.addWidget(self.updated) + summary_layout.addStretch() + summary_layout.addWidget(self.attached) + summary_layout.addWidget(self.starred) + layout.addWidget(self.summary) + self.name = QLabel() + layout.addWidget(self.name) + self.details = QLabel() + self.details.setWordWrap(True) + layout.addWidget(self.details) + self.update() + + def update(self): + """ + Updates the displayed values with the current values from self.source. + """ + self.updated.setText("5 minutes ago") # str(self.source.last_updated)) + """ + if self.source.is_starred: + self.starred.setText("[*]") + else: + self.starred.setText("[ ]") + """ + self.name.setText(self.source) # self.source.journalist_designation) + self.details.setText("Lorum ipsum dolor sit amet thingy dodah...") -class SourceView(QPlainTextEdit): - pass class LoginView(QWidget): - pass + """ + A widget to display the login form. + """ -class LogoutView(QWidget): - pass + def __init__(self, parent): + super().__init__(parent) + main_layout = QHBoxLayout() + main_layout.addStretch() + self.setLayout(main_layout) + form = QWidget() + layout = QVBoxLayout() + form.setLayout(layout) + main_layout.addWidget(form) + main_layout.addStretch() + self.title = QLabel(_('

Sign In

')) + self.title.setTextFormat(Qt.RichText) + self.instructions = QLabel(_('You may read all documents and messages ' + 'shown here, without signing in. To ' + 'correspond with a Source or to check ' + 'the server for updates, you must sign ' + 'in.')) + self.instructions.setWordWrap(True) + self.username_label = QLabel(_('Username')) + self.username_field = QLineEdit() + self.password_label = QLabel(_('Password')) + self.password_field = QLineEdit() + self.password_field.setEchoMode(QLineEdit.Password) + self.tfa_label = QLabel(_('Two-Factor Number')) + self.tfa_field = QLineEdit() + self.tfa_field.setEchoMode(QLineEdit.Password) + gutter = QWidget(self) + gutter_layout = QHBoxLayout() + gutter.setLayout(gutter_layout) + self.help_url = QLabel(_('Trouble Signing In?')) + self.help_url.setTextFormat(Qt.RichText) + self.help_url.setOpenExternalLinks(True) + self.submit = QPushButton(_('Sign In')) + gutter_layout.addWidget(self.help_url) + gutter_layout.addWidget(self.submit) + layout.addStretch() + layout.addWidget(self.title) + layout.addWidget(self.instructions) + layout.addWidget(self.username_label) + layout.addWidget(self.username_field) + layout.addWidget(self.password_label) + layout.addWidget(self.password_field) + layout.addWidget(self.tfa_label) + layout.addWidget(self.tfa_field) + layout.addWidget(gutter) + layout.addStretch() diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py new file mode 100644 index 000000000..9c18242ea --- /dev/null +++ b/securedrop_client/logic.py @@ -0,0 +1,55 @@ +""" +Contains the core logic for the application in the Client class. + +Copyright (C) 2018 The Freedom of the Press Foundation. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +import logging + +logger = logging.getLogger(__name__) + + +class Client: + """ + Represents the logic for the secure drop client application. In an MVC + application, this is the controller. + """ + + def __init__(self, gui, api, session): + """ + The gui, api and session objects are used to coordinate with the + various other layers of the application: the user interface, + SecureDrop proxy and SqlAlchemy local storage respectively. + """ + self.gui = gui # Reference to the UI window. + self.api = api # Reference to the API for secure drop proxy. + self.session = session # Reference to the SqlAlchemy session. + # The gui needs to reference this "controller" layer to call methods + # triggered by UI events. + self.gui.controller = self + + def setup(self): + """ + Setup the application with the default state of: + + * Not logged in. + * Show most recent state of syncronised sources. + * Show the login screen. + """ + self.gui.show_login() + # TODO: Pass in model classes. + self.gui.show_sources(["Benign Artichoke", "Last Unicorn", + "Jocular Beehive", "Sanitary Lemming", + "Spectacular Tuba", ]) diff --git a/securedrop_client/resources/__init__.py b/securedrop_client/resources/__init__.py new file mode 100644 index 000000000..5ed50da42 --- /dev/null +++ b/securedrop_client/resources/__init__.py @@ -0,0 +1,65 @@ +""" +Functions needed to work with non-code resources such as images (icons and SVG +files) and CSS (for configuring the look of the UI). + +Copyright (C) 2018 The Freedom of the Press Foundation. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +from pkg_resources import resource_filename, resource_string +from PyQt5.QtGui import QPixmap, QIcon +from PyQt5.QtSvg import QSvgWidget +from PyQt5.QtCore import QDir + + +# Add the images and CSS directories to the search path. +QDir.addSearchPath('images', resource_filename(__name__, 'images')) +QDir.addSearchPath('css', resource_filename(__name__, 'css')) + + +def path(name, resource_dir="images/"): + """ + Return the filename for the referenced image. + + Qt uses unix path conventions. + """ + return resource_filename(__name__, resource_dir + name) + + +def load_icon(name): + """ + Return a QIcon representation of a file in the resources. + """ + return QIcon(path(name)) + + +def load_svg(name): + """ + Return a QSvgWidget representation of a file in the resources. + """ + return QSvgWidget(path(name)) + + +def load_image(name): + """ + Return a QPixmap representation of a file in the resources. + """ + return QPixmap(path(name)) + + +def load_css(name): + """ + Return the contents of the referenced CSS file in the resources. + """ + return resource_string(__name__, "css/" + name).decode('utf-8') diff --git a/securedrop_client/resources/css/sdclient.css b/securedrop_client/resources/css/sdclient.css new file mode 100644 index 000000000..53afed185 --- /dev/null +++ b/securedrop_client/resources/css/sdclient.css @@ -0,0 +1,3 @@ +QPushButton { + background-color: #00ff00; +} diff --git a/securedrop_client/resources/images/icon.png b/securedrop_client/resources/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5ea817fb1f4c92adf878f477b833e170a510c3dc GIT binary patch literal 19723 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 eg<_Pz diff --git a/securedrop_client/resources/images/star_off.svg b/securedrop_client/resources/images/star_off.svg new file mode 100644 index 000000000..3997408ec --- /dev/null +++ b/securedrop_client/resources/images/star_off.svg @@ -0,0 +1 @@ + diff --git a/securedrop_client/resources/images/star_on.svg b/securedrop_client/resources/images/star_on.svg new file mode 100644 index 000000000..c2ac8f39e --- /dev/null +++ b/securedrop_client/resources/images/star_on.svg @@ -0,0 +1 @@ + diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py new file mode 100644 index 000000000..81f49cff5 --- /dev/null +++ b/tests/gui/test_main.py @@ -0,0 +1,69 @@ +""" +Check the core Window UI class works as expected. +""" +from PyQt5.QtWidgets import QApplication, QVBoxLayout +from securedrop_client.gui.main import Window +from securedrop_client.resources import load_icon +from unittest import mock + + +app = QApplication([]) + + +def test_init(): + """ + Ensure the Window instance is setup in the expected manner. + """ + mock_li = mock.MagicMock(return_value=load_icon('icon.png')) + mock_lo = mock.MagicMock(return_value=QVBoxLayout()) + mock_lo().addWidget = mock.MagicMock() + with mock.patch('securedrop_client.gui.main.load_icon', mock_li), \ + mock.patch('securedrop_client.gui.main.ToolBar') as mock_tb, \ + mock.patch('securedrop_client.gui.main.MainView') as mock_mv, \ + mock.patch('securedrop_client.gui.main.QVBoxLayout', mock_lo), \ + mock.patch('securedrop_client.gui.main.QMainWindow') as mock_qmw: + w = Window() + assert w.controller is None + mock_li.assert_called_once_with(w.icon) + mock_tb.assert_called_once_with(w.widget) + mock_mv.assert_called_once_with(w.widget) + assert mock_lo().addWidget.call_count == 2 + + +def test_autosize_window(): + """ + Check the autosizing fits to the full screen size. + """ + w = Window() + w.resize = mock.MagicMock() + mock_screen = mock.MagicMock() + mock_screen.width.return_value = 1024 + mock_screen.height.return_value = 768 + mock_sg = mock.MagicMock() + mock_sg.screenGeometry.return_value = mock_screen + mock_qdw = mock.MagicMock(return_value=mock_sg) + with mock.patch('securedrop_client.gui.main.QDesktopWidget', mock_qdw): + w.autosize_window() + w.resize.assert_called_once_with(1024, 768) + + +def test_show_login(): + """ + Ensures the update_view is called with a LoginView instance. + """ + w = Window() + w.main_view = mock.MagicMock() + mock_login_view = mock.MagicMock() + with mock.patch('securedrop_client.gui.main.LoginView', mock_login_view): + w.show_login() + w.main_view.update_view.assert_called_once_with(mock_login_view(w)) + + +def test_show_sources(): + """ + Ensure the sources list is passed to the source list widget to be updated. + """ + w = Window() + w.main_view = mock.MagicMock() + w.show_sources([1, 2, 3]) + w.main_view.source_list.update.assert_called_once_with([1, 2, 3]) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py new file mode 100644 index 000000000..b2f142e2c --- /dev/null +++ b/tests/gui/test_widgets.py @@ -0,0 +1,89 @@ +""" +Make sure the UI widgets are configured correctly and work as expected. +""" +from PyQt5.QtWidgets import QLineEdit, QWidget +from securedrop_client.gui.widgets import (ToolBar, MainView, SourceList, + SourceWidget, LoginView) +from unittest import mock + + +def test_ToolBar_init(): + """ + Ensure the ToolBar instance is correctly set up. + """ + tb = ToolBar(None) + assert "Synchronized: " in tb.status.text() + + +def test_MainView_init(): + """ + Ensure the MainView instance is correctly set up. + """ + mv = MainView(None) + assert isinstance(mv.source_list, SourceList) + assert isinstance(mv.filter_term, QLineEdit) + assert isinstance(mv.view_holder, QWidget) + + +def test_MainView_update_view(): + """ + Ensure the passed-in widget is added to the layout of the main view holder + (i.e. that area of the screen on the right hand side). + """ + mv = MainView(None) + mv.view_holder = mock.MagicMock() + mock_layout = mock.MagicMock() + mock_widget = mock.MagicMock() + with mock.patch('securedrop_client.gui.widgets.QVBoxLayout', mock_layout): + mv.update_view(mock_widget) + mv.view_holder.setLayout.assert_called_once_with(mock_layout()) + mock_layout().addWidget.assert_called_once_with(mock_widget) + + +def test_SourceList_update(): + """ + Check the items in the source list are cleared and a new SourceWidget for + each passed-in source is created along with an associated QListWidgetItem. + """ + sl = SourceList() + sl.clear = mock.MagicMock() + sl.addItem = mock.MagicMock() + sl.setItemWidget = mock.MagicMock() + mock_sw = mock.MagicMock() + mock_lwi = mock.MagicMock() + with mock.patch('securedrop_client.gui.widgets.SourceWidget', mock_sw), \ + mock.patch('securedrop_client.gui.widgets.QListWidgetItem', + mock_lwi): + sources = ['a', 'b', 'c', ] + sl.update(sources) + sl.clear.assert_called_once_with() + assert mock_sw.call_count == len(sources) + assert mock_lwi.call_count == len(sources) + assert sl.addItem.call_count == len(sources) + assert sl.setItemWidget.call_count == len(sources) + + +def test_SourceWidget_init(): + """ + The source widget is initialised with the passed-in source. + """ + sw = SourceWidget('foo', None) + assert sw.source == 'foo' + + +def test_SourceWidget_update(): + """ + Ensure the widget displays the expected details from the source. + """ + sw = SourceWidget('foo', None) + sw.name = mock.MagicMock() + sw.update() + sw.name.setText.assert_called_once_with('foo') + + +def test_LoginView_init(): + """ + The LoginView is correctly initialised. + """ + lv = LoginView(None) + assert lv.title.text() == '

Sign In

' diff --git a/tests/test_app.py b/tests/test_app.py index 6c11b4636..44f11eec1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -49,6 +49,10 @@ def test_run(): with mock.patch('securedrop_client.app.configure_logging') as conf_log, \ mock.patch('securedrop_client.app.QApplication') as mock_app, \ mock.patch('securedrop_client.app.Window') as mock_win, \ + mock.patch('securedrop_client.app.Client') as mock_client, \ mock.patch('securedrop_client.app.sys') as mock_sys: run() conf_log.assert_called_once_with() + mock_app.assert_called_once_with(mock_sys.argv) + mock_win.assert_called_once_with() + mock_client.assert_called_once_with(mock_win(), None, None) diff --git a/tests/test_logic.py b/tests/test_logic.py new file mode 100644 index 000000000..c5a220474 --- /dev/null +++ b/tests/test_logic.py @@ -0,0 +1,34 @@ +""" +Make sure the Client object, containing the application logic, behaves as +expected. +""" +from securedrop_client.logic import Client +from unittest import mock + + +def test_Client_init(): + """ + The passed in gui, app and session instances are correctly referenced and, + where appropriate, have a reference back to the client. + """ + mock_gui = mock.MagicMock() + mock_api = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client(mock_gui, mock_api, mock_session) + assert cl.gui == mock_gui + assert cl.api == mock_api + assert cl.session == mock_session + assert cl.gui.controller == cl + + +def test_Client_setup(): + """ + Ensure the application is set up with the following default state: + """ + mock_gui = mock.MagicMock() + mock_api = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client(mock_gui, mock_api, mock_session) + cl.setup() + cl.gui.show_login.assert_called_once_with() + assert cl.gui.show_sources.call_count == 1 diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..73d13e6f3 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,55 @@ +""" +Tests for the resources sub-module. +""" +import securedrop_client.resources +from unittest import mock +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtSvg import QSvgWidget + + +def test_path(): + """ + Ensure the resource_filename function is called with the expected args and + the path function under test returns its result. + """ + with mock.patch('securedrop_client.resources.resource_filename', + return_value='bar') as r: + assert securedrop_client.resources.path('foo') == 'bar' + r.assert_called_once_with(securedrop_client.resources.__name__, + 'images/foo') + + +def test_load_icon(): + """ + Check the load_icon function returns the expected QIcon object. + """ + result = securedrop_client.resources.load_icon('icon') + assert isinstance(result, QIcon) + + +def test_load_svg(): + """ + Check the load_svg function returns the expected QSvgWidget object. + """ + result = securedrop_client.resources.load_svg('paperclip.svg') + assert isinstance(result, QSvgWidget) + + +def test_load_image(): + """ + Check the load_image function returns the expected QPixmap object. + """ + result = securedrop_client.resources.load_image('icon') + assert isinstance(result, QPixmap) + + +def test_load_css(): + """ + Ensure the resource_string function is called with the expected args and + the load_css function returns its result. + """ + with mock.patch('securedrop_client.resources.resource_string', + return_value=b'foo') as rs: + assert 'foo' == securedrop_client.resources.load_css('foo') + rs.assert_called_once_with(securedrop_client.resources.__name__, + 'css/foo') From 6ea2fea26181da142608e777932c364f5d02ed1a Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 26 Sep 2018 14:43:49 +0100 Subject: [PATCH 3/9] Spike for non-blocking API calls. WiP. --- securedrop_client/logic.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 9c18242ea..f657b0286 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -17,10 +17,41 @@ along with this program. If not, see . """ import logging +from PyQt5.QtCore import QObject, QThread, pyqtSignal + logger = logging.getLogger(__name__) +class APICallRunner(QObject): + """ + Used to call the SecureDrop API in a non-blocking manner. + """ + + call_finished = pyqtSignal(str) + + def __init__(self, api_call, *args, **kwargs): + """ + Initialise with the function to call the API and any associated + args and kwargs. + """ + self.api_call = api_call + self.args = args + self.kwargs = kwargs + + def call_api(self): + """ + Call the API. + """ + try: + # TODO: Log details of the API call being made. + result = self.api_call(self.args, self.kwargs) + except Exception as ex: + logger.error(ex) + result = '' + self.call_finished.emit(result) + + class Client: """ Represents the logic for the secure drop client application. In an MVC From 58752ac757649a20fb25a5680fd60b2e65de4c39 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 3 Oct 2018 17:10:34 +0100 Subject: [PATCH 4/9] Login form an sources syncs with local SD instance. With tests! --- securedrop_client/app.py | 8 +- securedrop_client/gui/main.py | 19 +- securedrop_client/gui/widgets.py | 78 ++++- securedrop_client/logic.py | 165 ++++++++-- securedrop_client/models.py | 7 +- securedrop_client/resources/css/sdclient.css | 4 + securedrop_client/storage.py | 52 +++- tests/gui/test_main.py | 25 +- tests/gui/test_widgets.py | 129 +++++++- tests/test_app.py | 8 +- tests/test_logic.py | 307 ++++++++++++++++++- tests/test_models.py | 3 +- tests/test_storage.py | 74 ++++- 13 files changed, 788 insertions(+), 91 deletions(-) diff --git a/securedrop_client/app.py b/securedrop_client/app.py index 0155d5548..548605918 100644 --- a/securedrop_client/app.py +++ b/securedrop_client/app.py @@ -20,6 +20,7 @@ import pathlib import os import sys +from sqlalchemy.orm import sessionmaker from PyQt5.QtWidgets import QApplication from PyQt5.QtCore import Qt from logging.handlers import TimedRotatingFileHandler @@ -27,6 +28,7 @@ from securedrop_client.logic import Client from securedrop_client.gui.main import Window from securedrop_client.resources import load_icon, load_css +from securedrop_client.models import engine LOG_DIR = os.path.join(str(pathlib.Path.home()), '.securedrop_client') @@ -92,8 +94,8 @@ def run(): gui = Window() app.setWindowIcon(load_icon(gui.icon)) app.setStyleSheet(load_css('sdclient.css')) - api = None # TODO: securedrop_sdk.API - session = None # TODO: SqlAlchemy session. - client = Client(gui, api, session) + Session = sessionmaker(bind=engine) + session = Session() + client = Client("http://localhost:8081/", gui, session) client.setup() sys.exit(app.exec_()) diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index b60b19182..8bee0af0d 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -60,7 +60,14 @@ def __init__(self): self.setCentralWidget(self.widget) self.show() self.autosize_window() - self.controller = None # To reference the Client (logic) instance. + + def setup(self, controller): + """ + Create references to the controller logic and instantiate the various + views used in the UI. + """ + self.controller = controller # Reference the Client logic instance. + self.login_view = LoginView(self, self.controller) def autosize_window(self): """ @@ -70,11 +77,15 @@ def autosize_window(self): screen = QDesktopWidget().screenGeometry() self.resize(screen.width(), screen.height()) - def show_login(self, error=False): + def show_login(self, error=None): """ - Show the login form. + Show the login form. If an error message is passed in, the login + form will display this too. """ - self.main_view.update_view(LoginView(self)) + self.login_view.reset() + if error: + self.login_view.error(error) + self.main_view.update_view(self.login_view) def show_sources(self, sources): """ diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 9199efeac..40ef7dcc1 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -31,6 +31,8 @@ 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): @@ -69,20 +71,21 @@ def __init__(self, parent): left_layout.addWidget(self.source_list) self.layout.addWidget(left_column, 2) self.view_holder = QWidget() + self.view_layout = QVBoxLayout() + self.view_holder.setLayout(self.view_layout) self.layout.addWidget(self.view_holder, 6) def update_view(self, widget): """ Update the view holder to contain the referenced widget. """ - layout = QVBoxLayout() - self.view_holder.setLayout(layout) - layout.addWidget(widget) + self.view_layout.takeAt(0) + self.view_layout.addWidget(widget) class SourceList(QListWidget): """ - Represents the list of sources. + Displays the list of sources. """ def update(self, sources): @@ -91,7 +94,7 @@ def update(self, sources): """ self.clear() for source in sources: - new_source = SourceWidget(source, self) + new_source = SourceWidget(self, source) list_item = QListWidgetItem(self) list_item.setSizeHint(new_source.sizeHint()) self.addItem(list_item) @@ -103,7 +106,7 @@ class SourceWidget(QWidget): Used to display summary information about a source in the list view. """ - def __init__(self, source, parent): + def __init__(self, parent, source): """ Set up the child widgets. """ @@ -134,15 +137,16 @@ def __init__(self, source, parent): def update(self): """ Updates the displayed values with the current values from self.source. + + TODO: Style this widget properly and work out what should be in the + self.details label. """ - self.updated.setText("5 minutes ago") # str(self.source.last_updated)) - """ + self.updated.setText(str(self.source.last_updated)) if self.source.is_starred: - self.starred.setText("[*]") + self.starred = load_svg('star_on.svg') else: - self.starred.setText("[ ]") - """ - self.name.setText(self.source) # self.source.journalist_designation) + self.starred = load_svg('star_off.svg') + self.name.setText(self.source.journalist_designation) self.details.setText("Lorum ipsum dolor sit amet thingy dodah...") @@ -151,8 +155,9 @@ class LoginView(QWidget): A widget to display the login form. """ - def __init__(self, parent): + def __init__(self, parent, controller): super().__init__(parent) + self.controller = controller main_layout = QHBoxLayout() main_layout.addStretch() self.setLayout(main_layout) @@ -184,8 +189,11 @@ def __init__(self, parent): self.help_url.setTextFormat(Qt.RichText) self.help_url.setOpenExternalLinks(True) self.submit = QPushButton(_('Sign In')) + self.submit.clicked.connect(self.validate) gutter_layout.addWidget(self.help_url) gutter_layout.addWidget(self.submit) + self.error_label = QLabel('') + self.error_label.setObjectName('error_label') layout.addStretch() layout.addWidget(self.title) layout.addWidget(self.instructions) @@ -196,4 +204,48 @@ def __init__(self, parent): layout.addWidget(self.tfa_label) layout.addWidget(self.tfa_field) layout.addWidget(gutter) + layout.addWidget(self.error_label) layout.addStretch() + + def reset(self): + """ + Resets the login form to the default state. + """ + self.username_field.setText('') + self.username_field.setFocus() + self.password_field.setText('') + self.tfa_field.setText('') + self.setDisabled(False) + self.error_label.setText('') + + def error(self, message): + """ + Ensures the passed in message is displayed as an error message. + """ + self.error_label.setText(message) + + def validate(self): + """ + Validate the user input -- we expect values for: + + * username (free text) + * password (free text) + * TFA token (numerals) + """ + self.setDisabled(True) + username = self.username_field.text() + password = self.password_field.text() + tfa_token = self.tfa_field.text() + if username and password and tfa_token: + try: + int(tfa_token) + except ValueError: + self.setDisabled(False) + self.error(_('Please use only numerals (no spaces) for the ' + 'two factor number.')) + return + self.controller.login(username, password, tfa_token) + else: + self.setDisabled(False) + self.error(_('Please enter a username, password and ' + 'two factor number.')) diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index f657b0286..d9ea4ef7a 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -17,7 +17,9 @@ along with this program. If not, see . """ import logging -from PyQt5.QtCore import QObject, QThread, pyqtSignal +import sdclientapi +from securedrop_client import storage +from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer logger = logging.getLogger(__name__) @@ -25,51 +27,72 @@ class APICallRunner(QObject): """ - Used to call the SecureDrop API in a non-blocking manner. + Used to call the SecureDrop API in a non-blocking manner. Will emit a + timeout signal after 5 seconds. """ - call_finished = pyqtSignal(str) + call_finished = pyqtSignal(bool) # Indicates there is a result. + timeout = pyqtSignal() # Indicates there was a timeout. def __init__(self, api_call, *args, **kwargs): """ Initialise with the function to call the API and any associated args and kwargs. """ + super().__init__() self.api_call = api_call self.args = args self.kwargs = kwargs + self.result = None def call_api(self): """ - Call the API. + Call the API. Emit a boolean signal to indicate the outcome of the + call. Timeout signal emitted after 5 seconds. Any return value or + exception raised is stored in self.result. """ + self.timer = QTimer() + self.timer.timeout.connect(lambda: self.timeout.emit()) + self.timer.setSingleShot(True) + self.timer.start(5000) try: - # TODO: Log details of the API call being made. - result = self.api_call(self.args, self.kwargs) + logger.info('Calling API with "{}" method'.format( + self.api_call.__name__)) + self.result = self.api_call(*self.args, **self.kwargs) + result_flag = bool(self.result) except Exception as ex: logger.error(ex) - result = '' - self.call_finished.emit(result) + self.result = ex + result_flag = False + self.call_finished.emit(result_flag) + def on_cancel_timeout(self): + """ + Handles a signal to indicate the timer should stop. + """ + self.timer.stop() -class Client: + +class Client(QObject): """ Represents the logic for the secure drop client application. In an MVC application, this is the controller. """ - def __init__(self, gui, api, session): + finish_api_call = pyqtSignal() # Acknowledges reciept of an API call. + + def __init__(self, hostname, gui, session): """ - The gui, api and session objects are used to coordinate with the - various other layers of the application: the user interface, - SecureDrop proxy and SqlAlchemy local storage respectively. + The hostname, gui and session objects are used to coordinate with the + various other layers of the application: the location of the SecureDrop + proxy, the user interface and SqlAlchemy local storage respectively. """ + super().__init__() + self.hostname = hostname # Location of the SecureDrop server. self.gui = gui # Reference to the UI window. - self.api = api # Reference to the API for secure drop proxy. + self.api = None # Reference to the API for secure drop proxy. self.session = session # Reference to the SqlAlchemy session. - # The gui needs to reference this "controller" layer to call methods - # triggered by UI events. - self.gui.controller = self + self.api_thread = None # Currently active API call thread. def setup(self): """ @@ -79,8 +102,108 @@ def setup(self): * Show most recent state of syncronised sources. * Show the login screen. """ + # The gui needs to reference this "controller" layer to call methods + # triggered by UI events. + self.gui.setup(self) + # If possible, update the UI with available sources. + self.update_sources() + # Show the login view. self.gui.show_login() - # TODO: Pass in model classes. - self.gui.show_sources(["Benign Artichoke", "Last Unicorn", - "Jocular Beehive", "Sanitary Lemming", - "Spectacular Tuba", ]) + + def call_api(self, function, callback, timeout, *args, **kwargs): + """ + Calls the function in a non-blocking manner. Upon completion calls the + callback with the result. Calls timeout if the API call emits a + timeout signal. Any further arguments are passed to the function to be + called. + """ + if not self.api_thread: + self.api_thread = QThread(self.gui) + self.api_runner = APICallRunner(function, *args, **kwargs) + self.api_runner.moveToThread(self.api_thread) + self.api_runner.call_finished.connect(callback) + self.api_runner.timeout.connect(timeout) + self.finish_api_call.connect(self.api_runner.on_cancel_timeout) + self.api_thread.started.connect(self.api_runner.call_api) + self.api_thread.finished.connect(self.call_reset) + self.api_thread.start() + + def call_reset(self): + """ + Clean up this object's state after an API call. + """ + if self.api_thread: + self.finish_api_call.emit() + self.api_runner = None + self.api_thread = None + + def login(self, username, password, totp): + """ + Given a username, password and time based one-time-passcode (TOTP), + create a new instance representing the SecureDrop api and authenticate. + """ + self.api = sdclientapi.API(self.hostname, username, password, totp) + self.call_api(self.api.authenticate, self.on_authenticate, + self.on_login_timeout) + + def on_authenticate(self, result): + """ + Handles the result of an authentication call against the API. + """ + self.call_reset() + if result: + # It worked! Sync with the API. + self.sync_api() + else: + # Failed to authenticate. Reset state with failure message. + self.api = None + error = _('There was a problem logging in. Please try again.') + self.gui.show_login(error=error) + + def on_login_timeout(self): + """ + Reset the form and indicate the error. + """ + self.call_reset() + self.api = None + error = _('The connection to SecureDrop timed out. Please try again.') + self.gui.show_login(error=error) + + def authenticated(self): + """ + Return a boolean indication that the connection to the API is + authenticated. + """ + return bool(self.api and self.api.token['token']) + + def sync_api(self): + """ + Grab data from the remote SecureDrop API in a non-blocking manner. + """ + if self.authenticated(): + self.call_api(storage.get_remote_data, self.on_synced, + self.on_login_timeout, self.api) + + def on_synced(self, result): + """ + Called when syncronisation of data via the API is complete. + """ + if result and isinstance(self.api_runner.result, tuple): + remote_sources, remote_submissions, remote_replies = \ + self.api_runner.result + self.call_reset() + storage.update_local_storage(self.session, remote_sources, + remote_submissions, + remote_replies) + else: + # How to handle a failure? Exceptions are already logged. Perhaps + # a message in the UI? + pass + self.update_sources() + + def update_sources(self): + """ + Display the updated list of sources with those found in local storage. + """ + sources = list(storage.get_local_sources(self.session)) + self.gui.show_sources(sources) diff --git a/securedrop_client/models.py b/securedrop_client/models.py index d83b4051d..cf3e2da00 100644 --- a/securedrop_client/models.py +++ b/securedrop_client/models.py @@ -56,9 +56,10 @@ class Submission(Base): backref=backref("submissions", order_by=id, cascade="delete")) - def __init__(self, source, uuid, filename): + def __init__(self, source, uuid, size, filename): self.source_id = source.id self.uuid = uuid + self.size = size self.filename = filename def __repr__(self): @@ -102,3 +103,7 @@ def __init__(self, username): def __repr__(self): return "".format(self.username) + + +# Populate the database. +Base.metadata.create_all(engine) diff --git a/securedrop_client/resources/css/sdclient.css b/securedrop_client/resources/css/sdclient.css index 53afed185..e2b32d2de 100644 --- a/securedrop_client/resources/css/sdclient.css +++ b/securedrop_client/resources/css/sdclient.css @@ -1,3 +1,7 @@ QPushButton { background-color: #00ff00; } + +QLabel#error_label { + color: #FF0000; +} diff --git a/securedrop_client/storage.py b/securedrop_client/storage.py index 3439990bc..59e9a9ce8 100644 --- a/securedrop_client/storage.py +++ b/securedrop_client/storage.py @@ -27,9 +27,34 @@ logger = logging.getLogger(__name__) -def sync_with_api(api, session): +def get_local_sources(session): """ - Synchronises sources and submissions from the remote server's API. + Return all source objects from the local database. + """ + return session.query(Source) + + +def get_local_submissions(session): + """ + Return all submission objects from the local database. + """ + return session.query(Submission) + + +def get_local_replies(session): + """ + Return all reply objects from the local database. + """ + return session.query(Reply) + + +def get_remote_data(api): + """ + Given an authenticated connection to the SecureDrop API, get sources, + submissions and replies from the remote server and return a tuple + containing lists of objects representing this data: + + (remote_sources, remote_submissions, remote_replies) """ remote_submissions = [] try: @@ -45,9 +70,19 @@ def sync_with_api(api, session): logger.info('Fetched {} remote submissions.'.format( len(remote_submissions))) logger.info('Fetched {} remote replies.'.format(len(remote_replies))) - local_sources = session.query(Source) - local_submissions = session.query(Submission) - local_replies = session.query(Reply) + return (remote_sources, remote_submissions, remote_replies) + + +def update_local_storage(session, remote_sources, remote_submissions, + remote_replies): + """ + Given a database session and collections of remote sources, submissions and + replies from the SecureDrop API, ensures the local database is updated + with this data. + """ + local_sources = get_local_sources(session) + local_submissions = get_local_submissions(session) + local_replies = get_local_replies(session) update_sources(remote_sources, local_sources, session) update_submissions(remote_submissions, local_submissions, session) update_replies(remote_replies, local_replies, session) @@ -72,7 +107,7 @@ def update_sources(remote_sources, local_sources, session): if s.uuid == source.uuid][0] local_source.journalist_designation = source.journalist_designation local_source.is_flagged = source.is_flagged - local_source.public_key = source.key + local_source.public_key = source.key['public'] local_source.interaction_count = source.interaction_count local_source.is_starred = source.is_starred local_source.last_updated = parse(source.last_updated) @@ -85,7 +120,7 @@ def update_sources(remote_sources, local_sources, session): ns = Source(uuid=source.uuid, journalist_designation=source.journalist_designation, is_flagged=source.is_flagged, - public_key=source.key, + public_key=source.key['public'], interaction_count=source.interaction_count, is_starred=source.is_starred, last_updated=parse(source.last_updated)) @@ -124,6 +159,7 @@ def update_submissions(remote_submissions, local_submissions, session): _, source_uuid = submission.source_url.rsplit('/', 1) source = session.query(Source).filter_by(uuid=source_uuid)[0] ns = Submission(source=source, uuid=submission.uuid, + size=submission.size, filename=submission.filename) session.add(ns) logger.info('Added new submission {}'.format(submission.uuid)) @@ -178,7 +214,7 @@ def find_or_create_user(username, session): Returns a user object representing the referenced username. If the username does not already exist in the data, a new instance is created. """ - user = session.query(User).filter_by(username=username) + user = list(session.query(User).filter_by(username=username)) if user: return user[0] new_user = User(username) diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index 81f49cff5..af6edbb3a 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -3,6 +3,7 @@ """ from PyQt5.QtWidgets import QApplication, QVBoxLayout from securedrop_client.gui.main import Window +from securedrop_client.gui.widgets import LoginView from securedrop_client.resources import load_icon from unittest import mock @@ -23,13 +24,24 @@ def test_init(): mock.patch('securedrop_client.gui.main.QVBoxLayout', mock_lo), \ mock.patch('securedrop_client.gui.main.QMainWindow') as mock_qmw: w = Window() - assert w.controller is None mock_li.assert_called_once_with(w.icon) mock_tb.assert_called_once_with(w.widget) mock_mv.assert_called_once_with(w.widget) assert mock_lo().addWidget.call_count == 2 +def test_setup(): + """ + Ensure the passed in controller is referenced and the various views are + instantiated as expected. + """ + w = Window() + mock_controller = mock.MagicMock() + w.setup(mock_controller) + assert w.controller == mock_controller + assert isinstance(w.login_view, LoginView) + + def test_autosize_window(): """ Check the autosizing fits to the full screen size. @@ -51,12 +63,15 @@ def test_show_login(): """ Ensures the update_view is called with a LoginView instance. """ + mock_controller = mock.MagicMock() w = Window() + w.setup(mock_controller) + w.login_view = mock.MagicMock() w.main_view = mock.MagicMock() - mock_login_view = mock.MagicMock() - with mock.patch('securedrop_client.gui.main.LoginView', mock_login_view): - w.show_login() - w.main_view.update_view.assert_called_once_with(mock_login_view(w)) + w.show_login("error message") + w.login_view.reset.assert_called_once_with() + w.login_view.error.assert_called_once_with("error message") + w.main_view.update_view.assert_called_once_with(w.login_view) def test_show_sources(): diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index b2f142e2c..3bd554895 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -31,13 +31,11 @@ def test_MainView_update_view(): (i.e. that area of the screen on the right hand side). """ mv = MainView(None) - mv.view_holder = mock.MagicMock() - mock_layout = mock.MagicMock() + mv.view_layout = mock.MagicMock() mock_widget = mock.MagicMock() - with mock.patch('securedrop_client.gui.widgets.QVBoxLayout', mock_layout): - mv.update_view(mock_widget) - mv.view_holder.setLayout.assert_called_once_with(mock_layout()) - mock_layout().addWidget.assert_called_once_with(mock_widget) + mv.update_view(mock_widget) + mv.view_layout.takeAt.assert_called_once_with(0) + mv.view_layout.addWidget.assert_called_once_with(mock_widget) def test_SourceList_update(): @@ -67,23 +65,128 @@ def test_SourceWidget_init(): """ The source widget is initialised with the passed-in source. """ - sw = SourceWidget('foo', None) - assert sw.source == 'foo' + mock_source = mock.MagicMock() + mock_source.journalist_designation = 'foo bar baz' + sw = SourceWidget(None, mock_source) + assert sw.source == mock_source -def test_SourceWidget_update(): +def test_SourceWidget_update_starred(): """ Ensure the widget displays the expected details from the source. """ - sw = SourceWidget('foo', None) + mock_source = mock.MagicMock() + mock_source.journalist_designation = 'foo bar baz' + mock_source.is_starred = True + sw = SourceWidget(None, mock_source) sw.name = mock.MagicMock() - sw.update() - sw.name.setText.assert_called_once_with('foo') + with mock.patch('securedrop_client.gui.widgets.load_svg') as mock_load: + sw.update() + mock_load.assert_called_once_with('star_on.svg') + sw.name.setText.assert_called_once_with('foo bar baz') + + +def test_SourceWidget_update_unstarred(): + """ + Ensure the widget displays the expected details from the source. + """ + mock_source = mock.MagicMock() + mock_source.journalist_designation = 'foo bar baz' + mock_source.is_starred = False + sw = SourceWidget(None, mock_source) + sw.name = mock.MagicMock() + with mock.patch('securedrop_client.gui.widgets.load_svg') as mock_load: + sw.update() + mock_load.assert_called_once_with('star_off.svg') + sw.name.setText.assert_called_once_with('foo bar baz') def test_LoginView_init(): """ The LoginView is correctly initialised. """ - lv = LoginView(None) + mock_controller = mock.MagicMock() + lv = LoginView(None, mock_controller) + assert lv.controller == mock_controller assert lv.title.text() == '

Sign In

' + + +def test_LoginView_reset(): + """ + Ensure the state of the login view is returned to the correct state. + """ + mock_controller = mock.MagicMock() + lv = LoginView(None, mock_controller) + lv.username_field = mock.MagicMock() + lv.password_field = mock.MagicMock() + lv.tfa_field = mock.MagicMock() + lv.setDisabled = mock.MagicMock() + lv.error_label = mock.MagicMock() + lv.reset() + lv.username_field.setText.assert_called_once_with('') + lv.password_field.setText.assert_called_once_with('') + lv.tfa_field.setText.assert_called_once_with('') + lv.setDisabled.assert_called_once_with(False) + lv.error_label.setText.assert_called_once_with('') + + +def test_LoginView_error(): + """ + Any error message passed in is assigned as the text for the error label. + """ + mock_controller = mock.MagicMock() + lv = LoginView(None, mock_controller) + lv.error_label = mock.MagicMock() + lv.error('foo') + lv.error_label.setText.assert_called_once_with('foo') + + +def test_LoginView_validate_no_input(): + """ + If the user doesn't provide input, tell them and give guidance. + """ + mock_controller = mock.MagicMock() + lv = LoginView(None, mock_controller) + lv.username_field.text = mock.MagicMock(return_value='') + lv.password_field.text = mock.MagicMock(return_value='') + lv.tfa_field.text = mock.MagicMock(return_value='') + lv.setDisabled = mock.MagicMock() + lv.error = mock.MagicMock() + lv.validate() + assert lv.setDisabled.call_count == 2 + assert lv.error.call_count == 1 + + +def test_LoginView_validate_input_non_numeric_2fa(): + """ + If the user doesn't provide numeric 2fa input, tell them and give + guidance. + """ + mock_controller = mock.MagicMock() + lv = LoginView(None, mock_controller) + lv.username_field.text = mock.MagicMock(return_value='foo') + lv.password_field.text = mock.MagicMock(return_value='bar') + lv.tfa_field.text = mock.MagicMock(return_value='baz') + lv.setDisabled = mock.MagicMock() + lv.error = mock.MagicMock() + lv.validate() + assert lv.setDisabled.call_count == 2 + assert lv.error.call_count == 1 + assert mock_controller.login.call_count == 0 + + +def test_LoginView_validate_input_ok(): + """ + Valid input from the user causes a call to the controller's login method. + """ + mock_controller = mock.MagicMock() + lv = LoginView(None, mock_controller) + lv.username_field.text = mock.MagicMock(return_value='foo') + lv.password_field.text = mock.MagicMock(return_value='bar') + lv.tfa_field.text = mock.MagicMock(return_value='123456') + lv.setDisabled = mock.MagicMock() + lv.error = mock.MagicMock() + lv.validate() + assert lv.setDisabled.call_count == 1 + assert lv.error.call_count == 0 + mock_controller.login.assert_called_once_with('foo', 'bar', '123456') diff --git a/tests/test_app.py b/tests/test_app.py index 44f11eec1..512bb1ec5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -46,13 +46,17 @@ def test_run(): """ Ensure the expected things are configured and the application is started. """ + mock_session_class = mock.MagicMock() with mock.patch('securedrop_client.app.configure_logging') as conf_log, \ mock.patch('securedrop_client.app.QApplication') as mock_app, \ mock.patch('securedrop_client.app.Window') as mock_win, \ mock.patch('securedrop_client.app.Client') as mock_client, \ - mock.patch('securedrop_client.app.sys') as mock_sys: + mock.patch('securedrop_client.app.sys') as mock_sys, \ + mock.patch('securedrop_client.app.sessionmaker', + return_value=mock_session_class): run() conf_log.assert_called_once_with() mock_app.assert_called_once_with(mock_sys.argv) mock_win.assert_called_once_with() - mock_client.assert_called_once_with(mock_win(), None, None) + mock_client.assert_called_once_with('http://localhost:8081/', + mock_win(), mock_session_class()) diff --git a/tests/test_logic.py b/tests/test_logic.py index c5a220474..c186f94a2 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -2,23 +2,75 @@ Make sure the Client object, containing the application logic, behaves as expected. """ -from securedrop_client.logic import Client +from securedrop_client import storage +from securedrop_client.logic import APICallRunner, Client from unittest import mock +def test_APICallRunner_init(): + """ + Ensure everything is set up as expected. + """ + mock_api_call = mock.MagicMock() + cr = APICallRunner(mock_api_call, 'foo', bar='baz') + assert cr.api_call == mock_api_call + assert cr.args == ('foo', ) + assert cr.kwargs == {'bar': 'baz', } + + +def test_APICallRunner_call_api(): + """ + A result is obtained so emit True and put the result in self.result. + """ + mock_api_call = mock.MagicMock(return_value='foo') + mock_api_call.__name__ = 'my_function' + cr = APICallRunner(mock_api_call, 'foo', bar='baz') + cr.call_finished = mock.MagicMock() + with mock.patch('securedrop_client.logic.QTimer') as mock_timer: + cr.call_api() + assert cr.timer == mock_timer() + assert cr.result == 'foo' + cr.call_finished.emit.assert_called_once_with(True) + + +def test_APICallRunner_with_exception(): + """ + An exception has occured so emit False. + """ + ex = Exception('boom') + mock_api_call = mock.MagicMock(side_effect=ex) + mock_api_call.__name__ = 'my_function' + cr = APICallRunner(mock_api_call, 'foo', bar='baz') + cr.call_finished = mock.MagicMock() + with mock.patch('securedrop_client.logic.QTimer') as mock_timer: + cr.call_api() + assert cr.result == ex + cr.call_finished.emit.assert_called_once_with(False) + + +def test_APICallRunner_on_cancel_timeout(): + """ + Ensure the timer's stop method is called. + """ + mock_api_call = mock.MagicMock() + cr = APICallRunner(mock_api_call, 'foo', bar='baz') + cr.timer = mock.MagicMock() + cr.on_cancel_timeout() + cr.timer.stop.assert_called_once_with() + + def test_Client_init(): """ The passed in gui, app and session instances are correctly referenced and, where appropriate, have a reference back to the client. """ mock_gui = mock.MagicMock() - mock_api = mock.MagicMock() mock_session = mock.MagicMock() - cl = Client(mock_gui, mock_api, mock_session) + cl = Client('http://localhost/', mock_gui, mock_session) + assert cl.hostname == 'http://localhost/' assert cl.gui == mock_gui - assert cl.api == mock_api assert cl.session == mock_session - assert cl.gui.controller == cl + assert cl.api_thread is None def test_Client_setup(): @@ -26,9 +78,248 @@ def test_Client_setup(): Ensure the application is set up with the following default state: """ mock_gui = mock.MagicMock() - mock_api = mock.MagicMock() mock_session = mock.MagicMock() - cl = Client(mock_gui, mock_api, mock_session) + cl = Client('http://localhost', mock_gui, mock_session) + cl.update_sources = mock.MagicMock() cl.setup() + cl.gui.setup.assert_called_once_with(cl) + cl.update_sources.assert_called_once_with() cl.gui.show_login.assert_called_once_with() - assert cl.gui.show_sources.call_count == 1 + + +def test_Client_call_api_existing_thread(): + """ + The client will ignore attempt to call API if an existing request is in + progress. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.api_thread = True + cl.call_api(mock.MagicMock(), mock.MagicMock(), mock.MagicMock()) + assert cl.api_thread is True + + +def test_Client_call_api(): + """ + A new thread and APICallRunner is created / setup. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.finish_api_call = mock.MagicMock() + with mock.patch('securedrop_client.logic.QThread') as mock_qthread, \ + mock.patch('securedrop_client.logic.APICallRunner') as mock_runner: + mock_api_call = mock.MagicMock() + mock_callback = mock.MagicMock() + mock_timeout = mock.MagicMock() + cl.call_api(mock_api_call, mock_callback, mock_timeout, 'foo', + bar='baz') + cl.api_thread.started.connect.\ + assert_called_once_with(cl.api_runner.call_api) + cl.api_thread.finished.connect.\ + assert_called_once_with(cl.call_reset) + cl.api_thread.start.assert_called_once_with() + cl.api_runner.moveToThread.assert_called_once_with(cl.api_thread) + cl.api_runner.call_finished.connect.\ + assert_called_once_with(mock_callback) + cl.api_runner.timeout.connect.assert_called_once_with(mock_timeout) + cl.finish_api_call.connect(cl.api_runner.on_cancel_timeout) + + +def test_Client_call_reset_no_thread(): + """ + The client will ignore an attempt to reset an API call is there's no such + call "in flight". + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.finish_api_call = mock.MagicMock() + cl.api_thread = None + cl.call_reset() + assert cl.finish_api_call.emit.call_count == 0 + + +def test_Client_call_reset(): + """ + Call reset emits the expected signal and resets the state of client + attributes. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.finish_api_call = mock.MagicMock() + cl.api_thread = True + cl.call_reset() + assert cl.finish_api_call.emit.call_count == 1 + assert cl.api_runner is None + assert cl.api_thread is None + + +def test_Client_login(): + """ + Ensures the API is called in the expected manner for logging in the user + given the username, password and 2fa token. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.call_api = mock.MagicMock() + with mock.patch('securedrop_client.logic.sdclientapi.API') as mock_api: + cl.login('username', 'password', '123456') + cl.call_api.assert_called_once_with(mock_api().authenticate, + cl.on_authenticate, + cl.on_login_timeout) + + +def test_Client_on_authenticate_failed(): + """ + If the server responds with a negative to the request to authenticate, make + sure the user knows. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.on_authenticate(False) + mock_gui.show_login.assert_called_once_with(error='There was a problem ' + 'logging in. Please try ' + 'again.') + + +def test_Client_on_authenticate_ok(): + """ + Ensure the client syncs when the user successfully logs in. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.sync_api = mock.MagicMock() + cl.on_authenticate(True) + cl.sync_api.assert_called_once_with() + + +def test_Client_on_login_timeout(): + """ + Reset the form if the API call times out. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.call_reset = mock.MagicMock() + cl.on_login_timeout() + cl.call_reset.assert_called_once_with() + mock_gui.show_login.assert_called_once_with(error='The connection to ' + 'SecureDrop timed out. Please ' + 'try again.') + + +def test_Client_authenticated_yes(): + """ + If the API is authenticated return True. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.api = mock.MagicMock() + cl.api.token = {'token': 'foo'} + assert cl.authenticated() is True + + +def test_Client_authenticated_no(): + """ + If the API is authenticated return True. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.api = mock.MagicMock() + cl.api.token = {'token': ''} + assert cl.authenticated() is False + + +def test_Client_authenticated_no_api(): + """ + If the API is authenticated return True. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.api = None + assert cl.authenticated() is False + + +def test_Client_sync_api_not_authenticated(): + """ + If the API isn't authenticated, don't sync. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.authenticated = mock.MagicMock(return_value=False) + cl.call_api = mock.MagicMock() + cl.sync_api() + assert cl.call_api.call_count == 0 + + +def test_Client_sync_api(): + """ + Sync the API is authenticated. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.authenticated = mock.MagicMock(return_value=True) + cl.call_api = mock.MagicMock() + cl.sync_api() + cl.call_api.assert_called_once_with(storage.get_remote_data, cl.on_synced, + cl.on_login_timeout, cl.api) + + +def test_Client_on_synced_no_result(): + """ + If there's no result to syncing, then don't attempt to update local storage + and perhaps implement some as-yet-undefined UI update. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.update_sources = mock.MagicMock() + with mock.patch('securedrop_client.logic.storage') as mock_storage: + cl.on_synced(False) + assert mock_storage.update_local_storage.call_count == 0 + cl.update_sources.assert_called_once_with() + + +def test_Client_on_synced_with_result(): + """ + If there's a result to syncing, then update local storage. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.update_sources = mock.MagicMock() + cl.api_runner = mock.MagicMock() + cl.api_runner.result = (1, 2, 3, ) + cl.call_reset = mock.MagicMock() + with mock.patch('securedrop_client.logic.storage') as mock_storage: + cl.on_synced(True) + cl.call_reset.assert_called_once_with() + mock_storage.update_local_storage.assert_called_once_with(mock_session, + 1, 2, 3) + cl.update_sources.assert_called_once_with() + + +def test_Client_update_sources(): + """ + Ensure the UI displays a list of the available sources from local data + store. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + with mock.patch('securedrop_client.logic.storage') as mock_storage: + mock_storage.get_local_sources.return_value = (1, 2, 3) + cl.update_sources() + mock_storage.get_local_sources.assert_called_once_with(mock_session) + mock_gui.show_sources.assert_called_once_with([1, 2, 3]) diff --git a/tests/test_models.py b/tests/test_models.py index 0142835c3..f9ba6522e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,7 +17,8 @@ def test_string_representation_of_submission(): source = Source(journalist_designation="testy test", uuid="test", is_flagged=False, public_key='test', interaction_count=1, is_starred=False, last_updated='test') - submission = Submission(source=source, uuid="test", filename="test.docx") + submission = Submission(source=source, uuid="test", size=123, + filename="test.docx") submission.__repr__() diff --git a/tests/test_storage.py b/tests/test_storage.py index 173521bcf..eb0988724 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,9 +3,14 @@ """ import pytest import uuid +import securedrop_client.models from dateutil.parser import parse from unittest import mock -from securedrop_client.storage import (sync_with_api, update_sources, +from securedrop_client.storage import (get_local_sources, + get_local_submissions, + get_local_replies, + get_remote_data, update_local_storage, + update_sources, update_submissions, update_replies, find_or_create_user) from sdclientapi import Source, Submission, Reply @@ -17,7 +22,8 @@ def make_remote_source(): in the following unit tests. """ return Source(add_star_url='foo', interaction_count=1, is_flagged=False, - is_starred=True, journalist_designation='foo', key='bar', + is_starred=True, journalist_designation='foo', + key={'public': 'bar'}, last_updated='2018-09-11T11:42:31.366649Z', number_of_documents=1, number_of_messages=1, remove_star_url='baz', replies_url='qux', @@ -49,7 +55,35 @@ def make_remote_reply(source_uuid, username='testymctestface'): source_url=source_url, uuid=str(uuid.uuid4())) -def test_sync_with_api_handles_api_error(): +def test_get_local_sources(): + """ + At this moment, just return all sources. + """ + mock_session = mock.MagicMock() + get_local_sources(mock_session) + mock_session.query.assert_called_once_with(securedrop_client.models.Source) + + +def test_get_local_submissions(): + """ + At this moment, just return all submissions. + """ + mock_session = mock.MagicMock() + get_local_submissions(mock_session) + mock_session.query.\ + assert_called_once_with(securedrop_client.models.Submission) + + +def test_get_local_replies(): + """ + At this moment, just return all replies. + """ + mock_session = mock.MagicMock() + get_local_replies(mock_session) + mock_session.query.assert_called_once_with(securedrop_client.models.Reply) + + +def test_get_remote_data_handles_api_error(): """ Ensure any error encountered when accessing the API is logged but the caller handles the exception. @@ -58,15 +92,14 @@ def test_sync_with_api_handles_api_error(): mock_api.get_sources.side_effect = Exception('BANG!') mock_session = mock.MagicMock() with pytest.raises(Exception): - sync_with_api(mock_api, mock_session) + get_remote_data(mock_api) -def test_sync_with_api(): +def test_get_remote_data(): """ - Assuming no errors getting data, check the expected functions to update - the state of the local database are called with the necessary data. + In the good case, a tuple of results is returned. """ - # Some source and submission objects from the API. + # Some source, submission and reply objects from the API. mock_api = mock.MagicMock() source = make_remote_source() mock_api.get_sources.return_value = [source, ] @@ -74,7 +107,24 @@ def test_sync_with_api(): mock_api.get_submissions.return_value = [submission, ] reply = mock.MagicMock() mock_api.get_all_replies.return_value = [reply, ] - # Some local source and submission objects from the local database. + sources, submissions, replies = get_remote_data(mock_api) + assert sources == [source, ] + assert submissions == [submission, ] + assert replies == [reply, ] + + +def test_update_local_storage(): + """ + Assuming no errors getting data, check the expected functions to update + the state of the local database are called with the necessary data. + """ + source = make_remote_source() + submission = mock.MagicMock() + reply = mock.MagicMock() + sources = [source, ] + submissions = [submission, ] + replies = [reply, ] + # Some local source, submission and reply objects from the local database. mock_session = mock.MagicMock() local_source = mock.MagicMock() local_submission = mock.MagicMock() @@ -85,7 +135,7 @@ def test_sync_with_api(): mock.patch('securedrop_client.storage.update_replies') as rpl_fn, \ mock.patch('securedrop_client.storage.update_submissions') \ as sub_fn: - sync_with_api(mock_api, mock_session) + update_local_storage(mock_session, sources, submissions, replies) src_fn.assert_called_once_with([source, ], [local_source, ], mock_session) rpl_fn.assert_called_once_with([reply, ], [local_replies, ], @@ -124,7 +174,7 @@ def test_update_sources(): assert local_source1.journalist_designation == \ source_update.journalist_designation assert local_source1.is_flagged == source_update.is_flagged - assert local_source1.public_key == source_update.key + assert local_source1.public_key == source_update.key['public'] assert local_source1.interaction_count == source_update.interaction_count assert local_source1.is_starred == source_update.is_starred assert local_source1.last_updated == parse(source_update.last_updated) @@ -136,7 +186,7 @@ def test_update_sources(): assert new_source.journalist_designation == \ source_create.journalist_designation assert new_source.is_flagged == source_create.is_flagged - assert new_source.public_key == source_create.key + assert new_source.public_key == source_create.key['public'] assert new_source.interaction_count == source_create.interaction_count assert new_source.is_starred == source_create.is_starred assert new_source.last_updated == parse(source_create.last_updated) From 584617c0ea0422bd206e88573f89073be58a3c05 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 3 Oct 2018 17:26:33 +0100 Subject: [PATCH 5/9] Ensure Qt 5.10.1 --- Pipfile | 2 +- Pipfile.lock | 78 ++++++++++++++++++++++++---------------------------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/Pipfile b/Pipfile index 5c6f14dea..1aaed432d 100644 --- a/Pipfile +++ b/Pipfile @@ -11,7 +11,7 @@ SQLALchemy = "*" alembic = "*" securedrop-sdk = {git = "https://github.com/freedomofpress/securedrop-sdk.git"} "pathlib2" = "*" -"pyqt5" = "*" +"pyqt5" = "==5.10.1" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 2a6f6df40..48a4b1b2e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "cb0973c3df383486407fa191b55f87708e8c2eaa740c55b0560986c13035b9c3" + "sha256": "ff5d23714efb0b7c8f425f68a37c0b1b5aaae1332cb2f9028d877d7f087fa9be" }, "pipfile-spec": 6, "requires": { @@ -46,30 +46,13 @@ }, "pyqt5": { "hashes": [ - "sha256:700b8bb0357bf0ac312bce283449de733f5773dfc77083664be188c8e964c007", - "sha256:76d52f3627fac8bfdbc4857ce52a615cd879abd79890cde347682ff9b4b245a2", - "sha256:7d0f7c0aed9c3ef70d5856e99f30ebcfe25a58300158dd46ee544cbe1c5b53db", - "sha256:d5dc2faf0aeacd0e8b69af1dc9f1276a64020193148356bb319bdfae22b78f88" + "sha256:1e652910bd1ffd23a3a48c510ecad23a57a853ed26b782cd54b16658e6f271ac", + "sha256:4db7113f464c733a99fcb66c4c093a47cf7204ad3f8b3bda502efcc0839ac14b", + "sha256:9c17ab3974c1fc7bbb04cc1c9dae780522c0ebc158613f3025fccae82227b5f7", + "sha256:f6035baa009acf45e5f460cf88f73580ad5dc0e72330029acd99e477f20a5d61" ], "index": "pypi", - "version": "==5.11.2" - }, - "pyqt5-sip": { - "hashes": [ - "sha256:3bcd8efae7798ce41aa7c3a052bd5ce1849f437530b8a717bae39197e780f505", - "sha256:4a3c5767d6c238d8c62d252ac59312fac8b2264a1e8a5670081d7f3545893005", - "sha256:67481d70fb0c7fb83e77b9025e15d0e78c7647c228eef934bd20ba716845a519", - "sha256:7b2e563e4e56adee00101a29913fdcc49cc714f6c4f7eb35449f493c3a88fc45", - "sha256:92a4950cba7ad7b7f67c09bdf80170ac225b38844b3a10f1271b02bace2ffc64", - "sha256:9309c10f9e648521cfe03b62f4658dad2314f81886062cb30e0ad31b337e14b0", - "sha256:9f524e60fa6113b50c48fbd869b2aef19833f3fe278097b1e7403e8f4dd5392c", - "sha256:a10f59ad65b34e183853e1387b68901f473a2041f7398fac87c4e445ab149830", - "sha256:abc2b2df469b4efb01d9dba4b804cbf0312f109ed74752dc3a37394a77d55b1f", - "sha256:c09c17009a2dd2a6317a14d3cea9b2300fdb2206cf9bc4bae0870d1919897935", - "sha256:c30c162e1430fd5a02207f1bd478e170c61d89fcca11ac6d8babb73cb33a86a8", - "sha256:f00ceceef75a2140fda737bd30847ac69b7d92fbd32b6ea7b387017e72176bd8" - ], - "version": "==4.19.12" + "version": "==5.10.1" }, "python-dateutil": { "hashes": [ @@ -86,7 +69,24 @@ }, "securedrop-sdk": { "git": "https://github.com/freedomofpress/securedrop-sdk.git", - "ref": "f18264d96bca9f7c00ba566a123586a50842e437" + "ref": "bb9ddc92d6b4cb246c6c66e67ea272a7a6dc009d" + }, + "sip": { + "hashes": [ + "sha256:09f9a4e6c28afd0bafedb26ffba43375b97fe7207bd1a0d3513f79b7d168b331", + "sha256:105edaaa1c8aa486662226360bd3999b4b89dd56de3e314d82b83ed0587d8783", + "sha256:1bb10aac55bd5ab0e2ee74b3047aa2016cfa7932077c73f602a6f6541af8cd51", + "sha256:265ddf69235dd70571b7d4da20849303b436192e875ce7226be7144ca702a45c", + "sha256:52074f7cb5488e8b75b52f34ec2230bc75d22986c7fe5cd3f2d266c23f3349a7", + "sha256:5ff887a33839de8fc77d7f69aed0259b67a384dc91a1dc7588e328b0b980bde2", + "sha256:74da4ddd20c5b35c19cda753ce1e8e1f71616931391caeac2de7a1715945c679", + "sha256:7d69e9cf4f8253a3c0dfc5ba6bb9ac8087b8239851f22998e98cb35cfe497b68", + "sha256:97bb93ee0ef01ba90f57be2b606e08002660affd5bc380776dd8b0fcaa9e093a", + "sha256:cf98150a99e43fda7ae22abe655b6f202e491d6291486548daa56cb15a2fcf85", + "sha256:d9023422127b94d11c1a84bfa94933e959c484f2c79553c1ef23c69fe00d25f8", + "sha256:e72955e12f4fccf27aa421be383453d697b8a44bde2cc26b08d876fd492d0174" + ], + "version": "==4.19.8" }, "six": { "hashes": [ @@ -97,10 +97,10 @@ }, "sqlalchemy": { "hashes": [ - "sha256:ef6569ad403520ee13e180e1bfd6ed71a0254192a934ec1dbd3dbf48f4aa9524" + "sha256:c5951d9ef1d5404ed04bae5a16b60a0779087378928f997a294d1229c6ca4d3e" ], "index": "pypi", - "version": "==1.2.11" + "version": "==1.2.12" } }, "develop": { @@ -121,10 +121,11 @@ }, "click": { "hashes": [ - "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", - "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" ], - "version": "==6.7" + "markers": "python_version != '3.1.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.3.*' and python_version != '3.0.*'", + "version": "==7.0" }, "coverage": { "hashes": [ @@ -163,13 +164,6 @@ "index": "pypi", "version": "==4.5.1" }, - "first": { - "hashes": [ - "sha256:3bb3de3582cb27071cfb514f00ed784dc444b7f96dc21e140de65fe00585c95e", - "sha256:41d5b64e70507d0c3ca742d68010a76060eea8a3d863e9b5130ab11a4a91aa0e" - ], - "version": "==2.0.1" - }, "flake8": { "hashes": [ "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0", @@ -195,11 +189,11 @@ }, "pip-tools": { "hashes": [ - "sha256:90bbe6731a6a34d339bf14d90cf2892475386c7d06c458208191ac9992110e0a", - "sha256:f11fc3bf1d87a0b4a68d4d595f619814e2396e92d75d7bdd2500edbf002ea6de" + "sha256:4a94997602848f77ff02f660c0fcdfeaf316924ebb236c865f9742ce212aa6f9", + "sha256:e45e5198ce3799068642ebb0e7c9be5520bcff944c0186f79c1199a2759c970a" ], "index": "pypi", - "version": "==2.0.2" + "version": "==3.0.0" }, "pluggy": { "hashes": [ @@ -233,11 +227,11 @@ }, "pytest": { "hashes": [ - "sha256:453cbbbe5ce6db38717d282b758b917de84802af4288910c12442984bde7b823", - "sha256:a8a07f84e680482eb51e244370aaf2caa6301ef265f37c2bdefb3dd3b663f99d" + "sha256:7e258ee50338f4e46957f9e09a0f10fb1c2d05493fa901d113a8dafd0790de4e", + "sha256:9332147e9af2dcf46cd7ceb14d5acadb6564744ddff1fe8c17f0ce60ece7d9a2" ], "index": "pypi", - "version": "==3.8.0" + "version": "==3.8.2" }, "pytest-cov": { "hashes": [ From 1b0f8c70355476e238ec9d274779be91b3d5b446 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 10 Oct 2018 20:54:24 +0100 Subject: [PATCH 6/9] Final updates. --- Pipfile | 1 + Pipfile.lock | 17 +++-- securedrop_client/__init__.py | 2 +- securedrop_client/gui/main.py | 24 +++++- securedrop_client/gui/widgets.py | 72 +++++++++++++++--- securedrop_client/logic.py | 39 +++++++++- securedrop_client/models.py | 1 + securedrop_client/resources/css/sdclient.css | 1 - .../resources/images/header_logo.png | Bin 0 -> 25240 bytes securedrop_client/storage.py | 41 ++++++---- tests/gui/test_main.py | 43 +++++++++++ tests/gui/test_widgets.py | 68 ++++++++++++++++- tests/test_logic.py | 57 ++++++++++++++ tests/test_storage.py | 34 +++++++-- 14 files changed, 355 insertions(+), 45 deletions(-) create mode 100644 securedrop_client/resources/images/header_logo.png diff --git a/Pipfile b/Pipfile index 1aaed432d..78e3c7760 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ alembic = "*" securedrop-sdk = {git = "https://github.com/freedomofpress/securedrop-sdk.git"} "pathlib2" = "*" "pyqt5" = "==5.10.1" +arrow = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 48a4b1b2e..bb2c89639 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ff5d23714efb0b7c8f425f68a37c0b1b5aaae1332cb2f9028d877d7f087fa9be" + "sha256": "416eafb715e0e3cff552ab4df3de0e63d293062aab7912f30f0cf730e1238e86" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,13 @@ "index": "pypi", "version": "==1.0.0" }, + "arrow": { + "hashes": [ + "sha256:a558d3b7b6ce7ffc74206a86c147052de23d3d4ef0e17c210dd478c53575c4cd" + ], + "index": "pypi", + "version": "==0.12.1" + }, "mako": { "hashes": [ "sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae" @@ -69,7 +76,7 @@ }, "securedrop-sdk": { "git": "https://github.com/freedomofpress/securedrop-sdk.git", - "ref": "bb9ddc92d6b4cb246c6c66e67ea272a7a6dc009d" + "ref": "156bea470c756e1d6d8592311af862af4089b59f" }, "sip": { "hashes": [ @@ -189,11 +196,11 @@ }, "pip-tools": { "hashes": [ - "sha256:4a94997602848f77ff02f660c0fcdfeaf316924ebb236c865f9742ce212aa6f9", - "sha256:e45e5198ce3799068642ebb0e7c9be5520bcff944c0186f79c1199a2759c970a" + "sha256:31b43e5f8d605fc84f7506199025460abcb98a29d12cc99db268f73e39cf55e5", + "sha256:b1ceca03b4a48346b2f6870565abb09d8d257d5b1524b4c6b222185bf26c3870" ], "index": "pypi", - "version": "==3.0.0" + "version": "==3.1.0" }, "pluggy": { "hashes": [ diff --git a/securedrop_client/__init__.py b/securedrop_client/__init__.py index a12708554..7008007cc 100644 --- a/securedrop_client/__init__.py +++ b/securedrop_client/__init__.py @@ -15,7 +15,7 @@ language_code = 'en' # DEBUG/TRANSLATE: override the language code here (e.g. to Chinese). # language_code = 'zh' -gettext.translation('mu', localedir=localedir, +gettext.translation('securedrop_client', localedir=localedir, languages=[language_code], fallback=True).install() diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index 8bee0af0d..dcc6d2910 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -50,7 +50,6 @@ def __init__(self): self.setWindowTitle(_("SecureDrop Client {}").format(__version__)) self.setWindowIcon(load_icon(self.icon)) self.widget = QWidget() - self.setWindowFlags(Qt.CustomizeWindowHint) widget_layout = QVBoxLayout() self.widget.setLayout(widget_layout) self.tool_bar = ToolBar(self.widget) @@ -67,6 +66,7 @@ def setup(self, controller): views used in the UI. """ self.controller = controller # Reference the Client logic instance. + self.tool_bar.setup(self, controller) self.login_view = LoginView(self, self.controller) def autosize_window(self): @@ -93,3 +93,25 @@ def show_sources(self, sources): sources. """ self.main_view.source_list.update(sources) + + def show_sync(self, updated_on): + """ + Display a message indicating the data-sync state. + """ + if updated_on: + self.main_view.status.setText('Last Sync: ' + + updated_on.humanize()) + else: + self.main_view.status.setText(_('Waiting to Synchronize')) + + def set_logged_in_as(self, username): + """ + Update the UI to show user logged in with username. + """ + self.tool_bar.set_logged_in_as(username) + + def logout(self): + """ + Update the UI to show the user is logged out. + """ + self.tool_bar.set_logged_out() diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 40ef7dcc1..4c20afe65 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -17,12 +17,13 @@ along with this program. If not, see . """ import logging +import arrow from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QListWidget, QTextEdit, QLabel, QToolBar, QAction, QWidget, QListWidgetItem, QHBoxLayout, QPushButton, QVBoxLayout, QLineEdit, QPlainTextEdit) -from securedrop_client.resources import load_svg +from securedrop_client.resources import load_svg, load_image logger = logging.getLogger(__name__) @@ -37,13 +38,59 @@ class ToolBar(QWidget): def __init__(self, parent): super().__init__(parent) - self.setMaximumHeight(48) layout = QHBoxLayout(self) - self.status = QLabel(_("Synchronized: 5 minutes ago.")) - self.user_state = QLabel(_("Logged in as: Testy McTestface")) - layout.addWidget(self.status) + self.logo = QLabel() + self.logo.setPixmap(load_image('header_logo.png')) + self.user_state = QLabel(_("Logged out.")) + self.login = QPushButton(_('Sign In')) + self.login.clicked.connect(self.on_login_clicked) + self.logout = QPushButton(_('Log Out')) + self.logout.clicked.connect(self.on_logout_clicked) + self.logout.setVisible(False) + layout.addWidget(self.logo) layout.addStretch() layout.addWidget(self.user_state) + layout.addWidget(self.login) + layout.addWidget(self.logout) + + def setup(self, window, controller): + """ + Store a reference to the GUI window object (through which all wider GUI + state is controlled). + + Assign a controller object (containing the application logic) to this + instance of the toolbar. + """ + self.window = window + self.controller = controller + + def set_logged_in_as(self, username): + """ + Update the UI to reflect that the user is logged in as "username". + """ + self.user_state.setText(_('Logged in as: ' + username)) + self.login.setVisible(False) + self.logout.setVisible(True) + + def set_logged_out(self): + """ + Update the UI to a logged out state. + """ + self.user_state.setText(_('Logged out.')) + self.login.setVisible(True) + self.logout.setVisible(False) + + def on_login_clicked(self): + """ + Called when the login button is clicked. + """ + self.window.show_login() + + def on_logout_clicked(self): + """ + Called when the logout button is clicked. + """ + self.controller.logout() class MainView(QWidget): @@ -59,6 +106,8 @@ def __init__(self, parent): left_column = QWidget(parent=self) left_layout = QVBoxLayout() left_column.setLayout(left_layout) + self.status = QLabel(_('Waiting to Synchronize')) + left_layout.addWidget(self.status) filter_widget = QWidget() filter_layout = QHBoxLayout() filter_widget.setLayout(filter_layout) @@ -117,18 +166,18 @@ def __init__(self, parent, source): self.summary = QWidget(self) summary_layout = QHBoxLayout() self.summary.setLayout(summary_layout) - self.updated = QLabel() self.attached = load_svg('paperclip.svg') self.attached.setMaximumSize(16, 16) self.starred = load_svg('star_on.svg') self.starred.setMaximumSize(16, 16) - summary_layout.addWidget(self.updated) + self.name = QLabel() + summary_layout.addWidget(self.name) summary_layout.addStretch() summary_layout.addWidget(self.attached) summary_layout.addWidget(self.starred) layout.addWidget(self.summary) - self.name = QLabel() - layout.addWidget(self.name) + self.updated = QLabel() + layout.addWidget(self.updated) self.details = QLabel() self.details.setWordWrap(True) layout.addWidget(self.details) @@ -141,12 +190,13 @@ def update(self): TODO: Style this widget properly and work out what should be in the self.details label. """ - self.updated.setText(str(self.source.last_updated)) + self.updated.setText(arrow.get(self.source.last_updated).humanize()) if self.source.is_starred: self.starred = load_svg('star_on.svg') else: self.starred = load_svg('star_off.svg') - self.name.setText(self.source.journalist_designation) + self.name.setText("{}".format( + self.source.journalist_designation)) self.details.setText("Lorum ipsum dolor sit amet thingy dodah...") diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index d9ea4ef7a..9a8cd0e62 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -16,8 +16,10 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ +import os import logging import sdclientapi +import arrow from securedrop_client import storage from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer @@ -93,6 +95,7 @@ def __init__(self, hostname, gui, session): self.api = None # Reference to the API for secure drop proxy. self.session = session # Reference to the SqlAlchemy session. self.api_thread = None # Currently active API call thread. + self.sync_flag = os.path.join(os.path.expanduser('~'), '.sdsync') def setup(self): """ @@ -101,6 +104,7 @@ def setup(self): * Not logged in. * Show most recent state of syncronised sources. * Show the login screen. + * Check the sync status every 30 seconds. """ # The gui needs to reference this "controller" layer to call methods # triggered by UI events. @@ -109,6 +113,10 @@ def setup(self): self.update_sources() # Show the login view. self.gui.show_login() + # Create a timer to check for sync status every 30 seconds. + self.sync_timer = QTimer() + self.sync_timer.timeout.connect(self.update_sync) + self.sync_timer.start(30000) def call_api(self, function, callback, timeout, *args, **kwargs): """ @@ -152,8 +160,9 @@ def on_authenticate(self, result): """ self.call_reset() if result: - # It worked! Sync with the API. + # It worked! Sync with the API and update the UI. self.sync_api() + self.gui.set_logged_in_as(self.api.username) else: # Failed to authenticate. Reset state with failure message. self.api = None @@ -184,6 +193,16 @@ def sync_api(self): self.call_api(storage.get_remote_data, self.on_synced, self.on_login_timeout, self.api) + def last_sync(self): + """ + Returns the time of last synchronisation with the remote SD server. + """ + try: + with open(self.sync_flag) as f: + return arrow.get(f.read()) + except Exception: + return None + def on_synced(self, result): """ Called when syncronisation of data via the API is complete. @@ -195,15 +214,33 @@ def on_synced(self, result): storage.update_local_storage(self.session, remote_sources, remote_submissions, remote_replies) + # Set last sync flag. + with open(self.sync_flag, 'w') as f: + f.write(arrow.now().format()) else: # How to handle a failure? Exceptions are already logged. Perhaps # a message in the UI? pass self.update_sources() + def update_sync(self): + """ + Updates the UI to show human time of last sync. + """ + self.gui.show_sync(self.last_sync()) + def update_sources(self): """ Display the updated list of sources with those found in local storage. """ sources = list(storage.get_local_sources(self.session)) self.gui.show_sources(sources) + self.update_sync() + + def logout(self): + """ + Reset the API object and force the UI to update into a logged out + state. + """ + self.api = None + self.gui.logout() diff --git a/securedrop_client/models.py b/securedrop_client/models.py index cf3e2da00..bc25f4408 100644 --- a/securedrop_client/models.py +++ b/securedrop_client/models.py @@ -96,6 +96,7 @@ def __repr__(self): class User(Base): __tablename__ = 'users' id = Column(Integer, primary_key=True) + uuid = Column(String(36), unique=True, nullable=False) username = Column(String(255), nullable=False, unique=True) def __init__(self, username): diff --git a/securedrop_client/resources/css/sdclient.css b/securedrop_client/resources/css/sdclient.css index e2b32d2de..af6086059 100644 --- a/securedrop_client/resources/css/sdclient.css +++ b/securedrop_client/resources/css/sdclient.css @@ -1,5 +1,4 @@ QPushButton { - background-color: #00ff00; } QLabel#error_label { diff --git a/securedrop_client/resources/images/header_logo.png b/securedrop_client/resources/images/header_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f32a4cb6023ba80024151e3606e3ddc6cf2b8e37 GIT binary patch literal 25240 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 p#UoS5_iGG&qToXQewSVQ{u_*foo bar baz') def test_SourceWidget_update_unstarred(): @@ -98,7 +160,7 @@ def test_SourceWidget_update_unstarred(): with mock.patch('securedrop_client.gui.widgets.load_svg') as mock_load: sw.update() mock_load.assert_called_once_with('star_off.svg') - sw.name.setText.assert_called_once_with('foo bar baz') + sw.name.setText.assert_called_once_with('foo bar baz') def test_LoginView_init(): diff --git a/tests/test_logic.py b/tests/test_logic.py index c186f94a2..208855c9a 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -2,6 +2,7 @@ Make sure the Client object, containing the application logic, behaves as expected. """ +import arrow from securedrop_client import storage from securedrop_client.logic import APICallRunner, Client from unittest import mock @@ -195,8 +196,11 @@ def test_Client_on_authenticate_ok(): mock_session = mock.MagicMock() cl = Client('http://localhost', mock_gui, mock_session) cl.sync_api = mock.MagicMock() + cl.api = mock.MagicMock() + cl.api.username = 'test' cl.on_authenticate(True) cl.sync_api.assert_called_once_with() + cl.gui.set_logged_in_as.assert_called_once_with('test') def test_Client_on_login_timeout(): @@ -276,6 +280,33 @@ def test_Client_sync_api(): cl.on_login_timeout, cl.api) +def test_Client_last_sync_with_file(): + """ + The flag indicating the time of the last sync with the API is stored in a + dotfile in the user's home directory. If such a file exists, ensure an + "arror" object (representing the date/time) is returned. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + timestamp = '2018-10-10 18:17:13+01:00' + with mock.patch("builtins.open", mock.mock_open(read_data=timestamp)): + result = cl.last_sync() + assert isinstance(result, arrow.Arrow) + assert result.format() == timestamp + + +def test_Client_last_sync_no_file(): + """ + If there's no sync file, then just return None. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + with mock.patch("builtins.open", mock.MagicMock(side_effect=Exception())): + assert cl.last_sync() is None + + def test_Client_on_synced_no_result(): """ If there's no result to syncing, then don't attempt to update local storage @@ -310,6 +341,19 @@ def test_Client_on_synced_with_result(): cl.update_sources.assert_called_once_with() +def test_Client_update_sync(): + """ + Cause the UI to update with the result of self.last_sync(). + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.last_sync = mock.MagicMock() + cl.update_sync() + assert cl.last_sync.call_count == 1 + cl.gui.show_sync.assert_called_once_with(cl.last_sync()) + + def test_Client_update_sources(): """ Ensure the UI displays a list of the available sources from local data @@ -323,3 +367,16 @@ def test_Client_update_sources(): cl.update_sources() mock_storage.get_local_sources.assert_called_once_with(mock_session) mock_gui.show_sources.assert_called_once_with([1, 2, 3]) + + +def test_Client_logout(): + """ + The API is reset to None and the UI is set to logged out state. + """ + mock_gui = mock.MagicMock() + mock_session = mock.MagicMock() + cl = Client('http://localhost', mock_gui, mock_session) + cl.api = mock.MagicMock() + cl.logout() + assert cl.api is None + cl.gui.logout.assert_called_once_with() diff --git a/tests/test_storage.py b/tests/test_storage.py index eb0988724..cdf0669e7 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -43,14 +43,15 @@ def make_remote_submission(source_uuid): uuid=str(uuid.uuid4())) -def make_remote_reply(source_uuid, username='testymctestface'): +def make_remote_reply(source_uuid, journalist_uuid='testymctestface'): """ Utility function for generating sdclientapi Reply instances to act upon in the following unit tests. The passed in source_uuid is used to generate a valid URL. """ source_url = '/api/v1/sources/{}'.format(source_uuid) - return Reply(filename='test.filename', journalist_username=username, + return Reply(filename='test.filename', journalist_uuid=journalist_uuid, + journalist_username='test', is_deleted_by_source=False, reply_url='test', size=1234, source_url=source_url, uuid=str(uuid.uuid4())) @@ -312,14 +313,31 @@ def test_update_replies(): assert mock_session.commit.call_count == 1 -def test_find_or_create_user_existing(): +def test_find_or_create_user_existing_uuid(): """ - Return an existing user object. + Return an existing user object with the referenced uuid. """ mock_session = mock.MagicMock() mock_user = mock.MagicMock() - mock_session.query().filter_by.return_value = [mock_user, ] - assert find_or_create_user('testymctestface', mock_session) == mock_user + mock_user.username = 'foobar' + mock_session.query().filter_by().one_or_none.return_value = mock_user + assert find_or_create_user('uuid', 'foobar', + mock_session) == mock_user + + +def test_find_or_create_user_existing_username(): + """ + Return an existing user object with the referenced username. + """ + mock_session = mock.MagicMock() + mock_user = mock.MagicMock() + mock_user.username = 'foobar' + mock_session.query().filter_by().one_or_none.return_value = mock_user + assert find_or_create_user('uuid', 'testymctestface', + mock_session) == mock_user + assert mock_user.username == 'testymctestface' + mock_session.add.assert_called_once_with(mock_user) + mock_session.commit.assert_called_once_with() def test_find_or_create_user_new(): @@ -327,8 +345,8 @@ def test_find_or_create_user_new(): Create and return a user object for an unknown username. """ mock_session = mock.MagicMock() - mock_session.query().filter_by.return_value = [] - new_user = find_or_create_user('unknown', mock_session) + mock_session.query().filter_by().one_or_none.return_value = None + new_user = find_or_create_user('uuid', 'unknown', mock_session) assert new_user.username == 'unknown' mock_session.add.assert_called_once_with(new_user) mock_session.commit.assert_called_once_with() From 6234d2baaca24dbdd0f7711bda506f74a65ec1c8 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 10 Oct 2018 21:02:24 +0100 Subject: [PATCH 7/9] Attempt to fix CI: explicit declaration of QApplication instance in tests. --- tests/gui/test_widgets.py | 5 ++++- tests/test_app.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 528c3c981..211b83db6 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1,12 +1,15 @@ """ Make sure the UI widgets are configured correctly and work as expected. """ -from PyQt5.QtWidgets import QLineEdit, QWidget +from PyQt5.QtWidgets import QLineEdit, QWidget, QApplication from securedrop_client.gui.widgets import (ToolBar, MainView, SourceList, SourceWidget, LoginView) from unittest import mock +app = QApplication([]) + + def test_ToolBar_init(): """ Ensure the ToolBar instance is correctly set up. diff --git a/tests/test_app.py b/tests/test_app.py index 512bb1ec5..b00f1e2ee 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,11 +2,15 @@ Tests for the app module, which sets things up and runs the application. """ import sys +from PyQt5.QtWidgets import QApplication from unittest import mock from securedrop_client.app import (LOG_DIR, LOG_FILE, ENCODING, excepthook, configure_logging, run) +app = QApplication([]) + + def test_excpethook(): """ Ensure the custom excepthook logs the error and calls sys.exit. From d986d1ced90f626035d7912633c5da93b5389c93 Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 10 Oct 2018 21:48:17 +0100 Subject: [PATCH 8/9] Add support for xvfb to run tests. --- .circleci/config.yml | 6 +----- Makefile | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c579a5bd..e6f1270d8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,8 +11,4 @@ jobs: command: | pipenv install --dev export PYTHONPATH=$PYTHONPATH:. # so alembic can get to Base metadata - pipenv run pytest -v - - - run: - name: Run flake8 - command: pipenv run flake8 + pipenv run make check diff --git a/Makefile b/Makefile index 77157abf7..af218afb2 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ clean: find . | grep -E "(__pycache__)" | xargs rm -rf test: clean - pytest + xvfb-run python -m pytest pyflakes: find . \( -name _build -o -name var -o -path ./docs -o -path \) -type d -prune -o -name '*.py' -print0 | $(XARGS) pyflakes @@ -33,6 +33,6 @@ pycodestyle: find . \( -name _build -o -name var \) -type d -prune -o -name '*.py' -print0 | $(XARGS) -n 1 pycodestyle --repeat --exclude=build/*,docs/*,.vscode/* --ignore=E731,E402,W504 coverage: clean - pytest --cov-config .coveragerc --cov-report term-missing --cov=securedrop_client tests/ + xvfb-run python -m pytest --cov-config .coveragerc --cov-report term-missing --cov=securedrop_client tests/ check: clean pycodestyle pyflakes coverage From b139b710ceb9e7f4e5b1f2dc7f17f684f592425a Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Wed, 10 Oct 2018 16:07:19 -0700 Subject: [PATCH 9/9] CI: Remove flake8, rely on make check for code style --- .circleci/config.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index afa1e7978..72d54d58d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,11 +11,7 @@ jobs: command: | pipenv install --dev export PYTHONPATH=$PYTHONPATH:. # so alembic can get to Base metadata - pipenv run make check - - - run: - name: Run flake8 - command: pipenv run flake8 + pipenv run make check - run: name: Check Python dependencies for known vulnerabilities