From 3aa5b37fc3829562e95da4668dea7b9b025357c8 Mon Sep 17 00:00:00 2001 From: John Hensley Date: Mon, 8 Jun 2020 17:20:14 -0400 Subject: [PATCH 1/4] Add black and isort --- Makefile | 23 ++++++++++++++- dev-requirements.in | 2 ++ dev-requirements.txt | 69 +++++++++++++++++++++++++++++++++++--------- pyproject.toml | 11 +++++++ setup.cfg | 3 +- 5 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 pyproject.toml diff --git a/Makefile b/Makefile index 0f4d00096..2d27f816e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,27 @@ DEFAULT_GOAL: help SHELL := /bin/bash +.PHONY: venv +venv: ## Provision a Python 3 virtualenv for development. + python3 -m venv .venv + .venv/bin/pip install --require-hashes -r dev-requirements.txt + +.PHONY: black +black: ## Format Python source code with black + @black setup.py securedrop_client tests + +.PHONY: check-black +check-black: ## Check Python source code formatting with black + @black --check --diff setup.py securedrop_client tests + +.PHONY: isort +isort: ## Run isort to organize Python imports + @isort --recursive setup.py securedrop_client tests + +.PHONY: check-isort +check-isort: ## Check Python import organization with isort + @isort --check-only --diff --recursive setup.py securedrop_client tests + .PHONY: mypy mypy: ## Run static type checker @mypy --ignore-missing-imports securedrop_client @@ -80,7 +101,7 @@ bandit: ## Run bandit with medium level excluding test-related folders bandit -ll --recursive . --exclude ./tests,./.venv .PHONY: check -check: clean bandit lint mypy test-random test-integration test-functional ## Run the full CI test suite +check: clean check-black check-isort bandit lint mypy test-random test-integration test-functional ## Run the full CI test suite .PHONY: update-pip-requirements update-pip-requirements: ## Updates all Python requirements files via pip-compile for Linux. diff --git a/dev-requirements.in b/dev-requirements.in index 405407671..77f2314d3 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -1,9 +1,11 @@ atomicwrites==1.2.1 attrs==18.2.0 +black==19.10b0 Click==7.0 coverage==4.5.1 flake8==3.6.0 flaky==3.6.1 +isort==4.3.21 MarkupSafe>=1.1 mccabe==0.6.1 more-itertools==4.3.0 diff --git a/dev-requirements.txt b/dev-requirements.txt index 8a5fe9a0e..837bfed33 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -11,6 +11,10 @@ apipkg==1.5 \ --hash=sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6 \ --hash=sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c \ # via execnet +appdirs==1.4.4 \ + --hash=sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41 \ + --hash=sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128 \ + # via black arrow==0.12.1 \ --hash=sha256:a558d3b7b6ce7ffc74206a86c147052de23d3d4ef0e17c210dd478c53575c4cd \ # via -r requirements.in @@ -21,7 +25,11 @@ atomicwrites==1.2.1 \ attrs==18.2.0 \ --hash=sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69 \ --hash=sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb \ - # via -r dev-requirements.in, pytest + # via -r dev-requirements.in, black, pytest +black==19.10b0 \ + --hash=sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b \ + --hash=sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539 \ + # via -r dev-requirements.in certifi==2018.10.15 \ --hash=sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c \ --hash=sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a \ @@ -33,7 +41,7 @@ chardet==3.0.4 \ click==7.0 \ --hash=sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13 \ --hash=sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7 \ - # via -r dev-requirements.in, pip-tools + # via -r dev-requirements.in, black, pip-tools coverage==4.5.1 \ --hash=sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba \ --hash=sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed \ @@ -89,6 +97,10 @@ importlib-metadata==1.6.0 \ --hash=sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f \ --hash=sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e \ # via pluggy, pytest +isort==4.3.21 \ + --hash=sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1 \ + --hash=sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd \ + # via -r dev-requirements.in mako==1.0.7 \ --hash=sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae \ # via -r requirements.in, alembic @@ -186,10 +198,10 @@ pathlib2==2.3.2 \ --hash=sha256:8eb170f8d0d61825e09a95b38be068299ddeda82f35e96c3301a8a5e7604cb83 \ --hash=sha256:d1aa2a11ba7b8f7b21ab852b1fb5afb277e1bb99d5dfc663380b5015c0d80c5a \ # via -r requirements.in -pip-tools==4.5.1 \ - --hash=sha256:693f30e451875796b1b25203247f0b4cf48a4c4a5ab7341f4f33ffd498cdcc98 \ - --hash=sha256:be9c796aa88b2eec5cabf1323ba1cb60a08212b84bfb75b8b4037a8ef8cb8cb6 \ - # via -r dev-requirements.in +pathspec==0.8.0 \ + --hash=sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0 \ + --hash=sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061 \ + # via black pillow==7.0.0 \ --hash=sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be \ --hash=sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946 \ @@ -214,6 +226,10 @@ pillow==7.0.0 \ --hash=sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636 \ --hash=sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865 \ # via mouseinfo, pyscreeze +pip-tools==4.5.1 \ + --hash=sha256:693f30e451875796b1b25203247f0b4cf48a4c4a5ab7341f4f33ffd498cdcc98 \ + --hash=sha256:be9c796aa88b2eec5cabf1323ba1cb60a08212b84bfb75b8b4037a8ef8cb8cb6 \ + # via -r dev-requirements.in pluggy==0.13.0 \ --hash=sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6 \ --hash=sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34 \ @@ -315,6 +331,12 @@ python-dateutil==2.7.5 \ python-editor==1.0.3 \ --hash=sha256:a3c066acee22a1c94f63938341d4fb374e3fdd69366ed6603d7b24bed1efc565 \ # via -r requirements.in, alembic +python3-xlib==0.15 \ + --hash=sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8 \ + # via mouseinfo, pyautogui +pytweening==1.0.3 \ + --hash=sha256:4b608a570f4dccf2201e898f643c2a12372eb1d71a3dbc7e778771b603ca248b \ + # via pyautogui pyyaml==5.3.1 \ --hash=sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97 \ --hash=sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76 \ @@ -328,12 +350,29 @@ pyyaml==5.3.1 \ --hash=sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c \ --hash=sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a \ # via -r dev-requirements.in, vcrpy -python3-xlib==0.15 \ - --hash=sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8 \ - # via mouseinfo, pyautogui -pytweening==1.0.3 \ - --hash=sha256:4b608a570f4dccf2201e898f643c2a12372eb1d71a3dbc7e778771b603ca248b \ - # via pyautogui +regex==2020.6.8 \ + --hash=sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a \ + --hash=sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938 \ + --hash=sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29 \ + --hash=sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae \ + --hash=sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387 \ + --hash=sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a \ + --hash=sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf \ + --hash=sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610 \ + --hash=sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9 \ + --hash=sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5 \ + --hash=sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3 \ + --hash=sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89 \ + --hash=sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded \ + --hash=sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754 \ + --hash=sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f \ + --hash=sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868 \ + --hash=sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd \ + --hash=sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910 \ + --hash=sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3 \ + --hash=sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac \ + --hash=sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c \ + # via black requests==2.20.0 \ --hash=sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c \ --hash=sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279 \ @@ -362,6 +401,10 @@ six==1.11.0 \ sqlalchemy==1.3.3 \ --hash=sha256:91c54ca8345008fceaec987e10924bf07dcab36c442925357e5a467b36a38319 \ # via -r requirements.in, alembic +toml==0.10.1 \ + --hash=sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f \ + --hash=sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88 \ + # via black typed-ast==1.4.1 \ --hash=sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355 \ --hash=sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919 \ @@ -384,7 +427,7 @@ typed-ast==1.4.1 \ --hash=sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe \ --hash=sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4 \ --hash=sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7 \ - # via -r dev-requirements.in, mypy + # via -r dev-requirements.in, black, mypy typing-extensions==3.7.4.2 \ --hash=sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5 \ --hash=sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae \ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..81c891231 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.black] +line-length = 100 + +[tool.isort] +line_length = 100 +indent = ' ' +multi_line_output = 3 +ensure_newline_before_comments = true +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true diff --git a/setup.cfg b/setup.cfg index 3a668631d..560c93a9d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,10 @@ [flake8] +extend-ignore = E203, E231, W503 exclude = .git, __pycache__, max-line-length = 100 -builtins = +builtins = _, From 7734825c0150100f6fab7778cdf6496e8ce5041d Mon Sep 17 00:00:00 2001 From: John Hensley Date: Tue, 16 Jun 2020 17:07:10 -0400 Subject: [PATCH 2/4] Change single quotes to double in version regex in update_version.sh --- update_version.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/update_version.sh b/update_version.sh index 74253a6bb..11fbb4220 100755 --- a/update_version.sh +++ b/update_version.sh @@ -11,7 +11,7 @@ if [ -z "$NEW_VERSION" ]; then fi # Get the old version from securedrop_client/__init__.py -old_version_regex="^__version__ = '(.*)'$" +old_version_regex='^__version__ = "(.*)"$' [[ "$(cat securedrop_client/__init__.py)" =~ $old_version_regex ]] OLD_VERSION=${BASH_REMATCH[1]} @@ -28,4 +28,4 @@ if [[ "$OSTYPE" == "darwin"* ]]; then else sed -i "s@$(echo "${OLD_VERSION}" | sed 's/\./\\./g')@$NEW_VERSION@g" securedrop_client/__init__.py sed -i "s@$(echo "${OLD_VERSION}" | sed 's/\./\\./g')@$NEW_VERSION@g" setup.py -fi \ No newline at end of file +fi From 361ba8821de684b077dcad34bea3d16df246a8c4 Mon Sep 17 00:00:00 2001 From: John Hensley Date: Tue, 23 Jun 2020 18:12:36 -0400 Subject: [PATCH 3/4] Apply black and isort --- securedrop_client/__init__.py | 2 +- securedrop_client/api_jobs/base.py | 38 +- securedrop_client/api_jobs/downloads.py | 199 +-- securedrop_client/api_jobs/sources.py | 11 +- securedrop_client/api_jobs/sync.py | 19 +- securedrop_client/api_jobs/updatestar.py | 10 +- securedrop_client/api_jobs/uploads.py | 64 +- securedrop_client/app.py | 101 +- securedrop_client/config.py | 12 +- securedrop_client/crypto.py | 68 +- securedrop_client/db.py | 258 ++-- securedrop_client/export.py | 160 +-- securedrop_client/gui/__init__.py | 15 +- securedrop_client/gui/main.py | 28 +- securedrop_client/gui/widgets.py | 736 +++++----- securedrop_client/logic.py | 219 +-- securedrop_client/queue.py | 96 +- securedrop_client/resources/__init__.py | 14 +- securedrop_client/storage.py | 242 ++-- securedrop_client/sync.py | 42 +- securedrop_client/utils.py | 20 +- setup.py | 18 +- tests/api_jobs/test_base.py | 37 +- tests/api_jobs/test_downloads.py | 244 ++-- tests/api_jobs/test_sources.py | 17 +- tests/api_jobs/test_sync.py | 26 +- tests/api_jobs/test_updatestar.py | 51 +- tests/api_jobs/test_uploads.py | 246 ++-- tests/conftest.py | 130 +- tests/factory.py | 79 +- tests/functional/test_download_file.py | 5 +- tests/functional/test_export_dialog.py | 11 +- tests/functional/test_login.py | 3 +- tests/functional/test_login_from_offline.py | 3 +- .../functional/test_offline_delete_source.py | 10 +- .../test_offline_read_conversations.py | 8 +- tests/functional/test_offline_send_reply.py | 3 +- tests/functional/test_offline_star_source.py | 5 +- tests/functional/test_receive_message.py | 5 +- tests/functional/test_send_reply.py | 2 +- tests/functional/test_star_source.py | 3 +- tests/functional/test_unstar_source.py | 3 +- tests/functional/test_user_icon_click.py | 2 +- tests/gui/test_init.py | 88 +- tests/gui/test_main.py | 61 +- tests/gui/test_widgets.py | 1277 +++++++++-------- tests/integration/conftest.py | 59 +- tests/integration/test_placeholder.py | 24 +- .../test_styles_file_download_button.py | 26 +- .../test_styles_modal_dialog_button.py | 12 +- .../test_styles_modal_dialog_error_details.py | 8 +- .../integration/test_styles_reply_message.py | 22 +- .../test_styles_reply_status_bar.py | 10 +- tests/integration/test_styles_sdclient.py | 360 ++--- .../test_styles_speech_bubble_message.py | 12 +- .../test_styles_speech_bubble_status_bar.py | 6 +- tests/test_alembic.py | 90 +- tests/test_app.py | 203 ++- tests/test_config.py | 13 +- tests/test_crypto.py | 140 +- tests/test_export.py | 339 +++-- tests/test_logic.py | 897 ++++++------ tests/test_models.py | 255 ++-- tests/test_queue.py | 144 +- tests/test_resources.py | 44 +- tests/test_storage.py | 522 ++++--- tests/test_sync.py | 81 +- tests/test_utils.py | 7 +- 68 files changed, 4131 insertions(+), 3834 deletions(-) diff --git a/securedrop_client/__init__.py b/securedrop_client/__init__.py index 7fd229a32..d3ec452c3 100644 --- a/securedrop_client/__init__.py +++ b/securedrop_client/__init__.py @@ -1 +1 @@ -__version__ = '0.2.0' +__version__ = "0.2.0" diff --git a/securedrop_client/api_jobs/base.py b/securedrop_client/api_jobs/base.py index 0685a15e1..0f061fbc3 100644 --- a/securedrop_client/api_jobs/base.py +++ b/securedrop_client/api_jobs/base.py @@ -1,23 +1,24 @@ import logging +from typing import Any, Optional, TypeVar from PyQt5.QtCore import QObject, pyqtSignal from sdclientapi import API, AuthError, RequestTimeoutError, ServerConnectionError from sqlalchemy.orm.session import Session -from typing import Any, Optional, TypeVar logger = logging.getLogger(__name__) DEFAULT_NUM_ATTEMPTS = 5 -QueueJobType = TypeVar('QueueJobType', bound='QueueJob') +QueueJobType = TypeVar("QueueJobType", bound="QueueJob") class ApiInaccessibleError(Exception): - def __init__(self, message: Optional[str] = None) -> None: if not message: - message = ('API is inaccessible either because there is no client or because the ' - 'client is not properly authenticated.') + message = ( + "API is inaccessible either because there is no client or because the " + "client is not properly authenticated." + ) super().__init__(message) @@ -27,15 +28,15 @@ def __init__(self) -> None: self.order_number = None # type: Optional[int] def __lt__(self, other: QueueJobType) -> bool: - ''' + """ Python's PriorityQueue requires that QueueJobs are sortable as it retrieves the next job using sorted(list(entries))[0]. For QueueJobs that have equal priority, we need to use the order_number key to break ties to ensure that objects are retrieved in FIFO order. - ''' + """ if self.order_number is None or other.order_number is None: - raise ValueError('cannot compare jobs without order_number!') + raise ValueError("cannot compare jobs without order_number!") return self.order_number < other.order_number @@ -47,14 +48,15 @@ def __init__(self) -> None: class ApiJob(QueueJob): - ''' + """ Signal that is emitted after an job finishes successfully. - ''' - success_signal = pyqtSignal('PyQt_PyObject') + """ + + success_signal = pyqtSignal("PyQt_PyObject") - ''' + """ Signal that is emitted if there is a failure during the job. - ''' + """ failure_signal = pyqtSignal(Exception) def __init__(self, remaining_attempts: int = DEFAULT_NUM_ATTEMPTS) -> None: @@ -83,23 +85,23 @@ def _do_call_api(self, api_client: API, session: Session) -> None: break def call_api(self, api_client: API, session: Session) -> Any: - ''' + """ Method for making the actual API call and handling the result. This MUST resturn a value if the API call and other tasks were successful and MUST raise an exception if and only if the tasks failed. Presence of a raise exception indicates a failure. - ''' + """ raise NotImplementedError class SingleObjectApiJob(ApiJob): def __init__(self, uuid: str, remaining_attempts: int = DEFAULT_NUM_ATTEMPTS) -> None: super().__init__(remaining_attempts) - ''' + """ UUID of the item (source, reply, submission, etc.) that this item corresponds to. We track this to prevent the addition of duplicate jobs. - ''' + """ self.uuid = uuid def __repr__(self) -> str: @@ -107,7 +109,7 @@ def __repr__(self) -> str: def __eq__(self, other: Any) -> bool: # type: ignore[override] # https://github.com/python/mypy/issues/2783 - if self.uuid == getattr(other, 'uuid', None) and type(self) == type(other): + if self.uuid == getattr(other, "uuid", None) and type(self) == type(other): return True else: return False diff --git a/securedrop_client/api_jobs/downloads.py b/securedrop_client/api_jobs/downloads.py index 1e5100c72..59a66c6ee 100644 --- a/securedrop_client/api_jobs/downloads.py +++ b/securedrop_client/api_jobs/downloads.py @@ -4,9 +4,8 @@ import math import os import shutil - from tempfile import NamedTemporaryFile -from typing import Any, Union, Tuple, Type +from typing import Any, Tuple, Type, Union from sdclientapi import API, BaseError from sdclientapi import Reply as SdkReply @@ -14,19 +13,21 @@ from sqlalchemy.orm.session import Session from securedrop_client.api_jobs.base import SingleObjectApiJob -from securedrop_client.crypto import GpgHelper, CryptoError +from securedrop_client.crypto import CryptoError, GpgHelper from securedrop_client.db import DownloadError, DownloadErrorCodes, File, Message, Reply -from securedrop_client.storage import mark_as_decrypted, mark_as_downloaded, \ - set_message_or_reply_content - +from securedrop_client.storage import ( + mark_as_decrypted, + mark_as_downloaded, + set_message_or_reply_content, +) logger = logging.getLogger(__name__) class DownloadException(Exception): - def __init__(self, message: str, - object_type: Union[Type[Reply], Type[Message], Type[File]], - uuid: str): + def __init__( + self, message: str, object_type: Union[Type[Reply], Type[Message], Type[File]], uuid: str + ): super().__init__(message) self.object_type = object_type self.uuid = uuid @@ -45,9 +46,9 @@ class DownloadDecryptionException(DownloadException): class DownloadJob(SingleObjectApiJob): - ''' + """ Download and decrypt a file that contains either a message, reply, or file submission. - ''' + """ CHUNK_SIZE = 4096 @@ -56,7 +57,7 @@ def __init__(self, data_dir: str, uuid: str) -> None: self.data_dir = data_dir def _get_realistic_timeout(self, size_in_bytes: int) -> int: - ''' + """ Return a realistic timeout in seconds based on the size of the download. This simply scales the timeouts per file so that it increases as the file size increases. @@ -81,53 +82,54 @@ def _get_realistic_timeout(self, size_in_bytes: int) -> int: set it to 100000 bytes/second. * Minimum timeout allowed is 25 seconds - ''' + """ TIMEOUT_BYTES_PER_SECOND = 100000.0 TIMEOUT_ADJUSTMENT_FACTOR = 1.5 TIMEOUT_BASE = 25 timeout = math.ceil((size_in_bytes / TIMEOUT_BYTES_PER_SECOND) * TIMEOUT_ADJUSTMENT_FACTOR) return timeout + TIMEOUT_BASE - def call_download_api(self, api: API, - db_object: Union[File, Message, Reply]) -> Tuple[str, str]: - ''' + def call_download_api( + self, api: API, db_object: Union[File, Message, Reply] + ) -> Tuple[str, str]: + """ Method for making the actual API call to downlod the file and handling the result. This MUST return the (etag, filepath) tuple response from the server and MUST raise an exception if and only if the download fails. - ''' + """ raise NotImplementedError def call_decrypt(self, filepath: str, session: Session = None) -> str: - ''' + """ Method for decrypting the file and storing the plaintext result. Returns the original filename. This MUST raise an exception if and only if the decryption fails. - ''' + """ raise NotImplementedError def get_db_object(self, session: Session) -> Union[File, Message]: - ''' + """ Get the database object associated with this job. - ''' + """ raise NotImplementedError def call_api(self, api_client: API, session: Session) -> Any: - ''' + """ Override ApiJob. Download and decrypt the file associated with the database object. - ''' + """ db_object = self.get_db_object(session) if db_object.is_decrypted: - logger.debug(f'item with uuid {self.uuid} already decrypted, returning') + logger.debug(f"item with uuid {self.uuid} already decrypted, returning") return db_object.uuid if db_object.is_downloaded: - logger.debug(f'item with uuid {self.uuid} already downloaded, now decrypting') + logger.debug(f"item with uuid {self.uuid} already downloaded, now decrypting") self._decrypt(db_object.location(self.data_dir), db_object, session) return db_object.uuid @@ -135,30 +137,27 @@ def call_api(self, api_client: API, session: Session) -> Any: self._decrypt(destination, db_object, session) return db_object.uuid - def _download(self, - api: API, - db_object: Union[File, Message, Reply], - session: Session) -> str: - ''' + def _download(self, api: API, db_object: Union[File, Message, Reply], session: Session) -> str: + """ Download the encrypted file. Check file integrity and move it to the data directory before marking it as downloaded. Note: On Qubes OS, files are downloaded to /home/user/QubesIncoming/sd-proxy - ''' + """ try: etag, download_path = self.call_download_api(api, db_object) if not self._check_file_integrity(etag, download_path): - download_error = session.query(DownloadError).filter_by( - name=DownloadErrorCodes.CHECKSUM_ERROR.name - ).one() + download_error = ( + session.query(DownloadError) + .filter_by(name=DownloadErrorCodes.CHECKSUM_ERROR.name) + .one() + ) db_object.download_error = download_error session.commit() exception = DownloadChecksumMismatchException( - 'Downloaded file had an invalid checksum.', - type(db_object), - db_object.uuid - ) + "Downloaded file had an invalid checksum.", type(db_object), db_object.uuid + ) raise exception destination = db_object.location(self.data_dir) @@ -171,80 +170,85 @@ def _download(self, except BaseError as e: raise e - def _decrypt(self, - filepath: str, - db_object: Union[File, Message, Reply], - session: Session) -> None: - ''' + def _decrypt( + self, filepath: str, db_object: Union[File, Message, Reply], session: Session + ) -> None: + """ Decrypt the file located at the given filepath and mark it as decrypted. - ''' + """ try: original_filename = self.call_decrypt(filepath, session) db_object.download_error = None mark_as_decrypted( - type(db_object), db_object.uuid, session, original_filename=original_filename) - logger.info(f'File decrypted to {os.path.dirname(filepath)}') + type(db_object), db_object.uuid, session, original_filename=original_filename + ) + logger.info(f"File decrypted to {os.path.dirname(filepath)}") except CryptoError as e: mark_as_decrypted(type(db_object), db_object.uuid, session, is_decrypted=False) - download_error = session.query(DownloadError).filter_by( - name=DownloadErrorCodes.DECRYPTION_ERROR.name - ).one() + download_error = ( + session.query(DownloadError) + .filter_by(name=DownloadErrorCodes.DECRYPTION_ERROR.name) + .one() + ) db_object.download_error = download_error session.commit() raise DownloadDecryptionException( - f'Failed to decrypt file: {os.path.basename(filepath)}', + f"Failed to decrypt file: {os.path.basename(filepath)}", type(db_object), - db_object.uuid + db_object.uuid, ) from e @classmethod def _check_file_integrity(cls, etag: str, file_path: str) -> bool: - ''' + """ Return True if file checksum is valid or unknown, otherwise return False. - ''' + """ if not etag: - logger.debug('No ETag. Skipping integrity check for file at {}'.format(file_path)) + logger.debug("No ETag. Skipping integrity check for file at {}".format(file_path)) return True - alg, checksum = etag.split(':') + alg, checksum = etag.split(":") - if alg == 'sha256': + if alg == "sha256": hasher = hashlib.sha256() else: - logger.debug('Unknown hash algorithm ({}). Skipping integrity check for file at {}' - .format(alg, file_path)) + logger.debug( + "Unknown hash algorithm ({}). Skipping integrity check for file at {}".format( + alg, file_path + ) + ) return True - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: while True: read_bytes = f.read(cls.CHUNK_SIZE) if not read_bytes: break hasher.update(read_bytes) - calculated_checksum = binascii.hexlify(hasher.digest()).decode('utf-8') + calculated_checksum = binascii.hexlify(hasher.digest()).decode("utf-8") return calculated_checksum == checksum class ReplyDownloadJob(DownloadJob): - ''' + """ Download and decrypt a reply from a source. - ''' + """ def __init__(self, uuid: str, data_dir: str, gpg: GpgHelper) -> None: super().__init__(data_dir, uuid) self.gpg = gpg def get_db_object(self, session: Session) -> Reply: - ''' + """ Override DownloadJob. - ''' + """ return session.query(Reply).filter_by(uuid=self.uuid).one() def call_download_api(self, api: API, db_object: Reply) -> Tuple[str, str]: - ''' + """ Override DownloadJob. - ''' + """ sdk_object = SdkReply(uuid=db_object.uuid, filename=db_object.filename) sdk_object.source_uuid = db_object.source.uuid @@ -255,7 +259,7 @@ def call_download_api(self, api: API, db_object: Reply) -> Tuple[str, str]: return api.download_reply(sdk_object) def call_decrypt(self, filepath: str, session: Session = None) -> str: - ''' + """ Override DownloadJob. Decrypt the file located at the given filepath and store its plaintext content in the local @@ -264,29 +268,27 @@ def call_decrypt(self, filepath: str, session: Session = None) -> str: The file containing the plaintext should be deleted once the content is stored in the db. The return value is an empty string; replies have no original filename. - ''' - with NamedTemporaryFile('w+') as plaintext_file: + """ + with NamedTemporaryFile("w+") as plaintext_file: try: self.gpg.decrypt_submission_or_reply(filepath, plaintext_file.name, is_doc=False) set_message_or_reply_content( - model_type=Reply, - uuid=self.uuid, - session=session, - content=plaintext_file.read()) + model_type=Reply, uuid=self.uuid, session=session, content=plaintext_file.read() + ) finally: try: os.rmdir(os.path.dirname(filepath)) except OSError: - msg = f'Could not delete decryption directory: {os.path.dirname(filepath)}' + msg = f"Could not delete decryption directory: {os.path.dirname(filepath)}" logger.debug(msg) return "" class MessageDownloadJob(DownloadJob): - ''' + """ Download and decrypt a message from a source. - ''' + """ def __init__(self, uuid: str, data_dir: str, gpg: GpgHelper) -> None: super().__init__(data_dir, uuid) @@ -294,23 +296,24 @@ def __init__(self, uuid: str, data_dir: str, gpg: GpgHelper) -> None: self.gpg = gpg def get_db_object(self, session: Session) -> Message: - ''' + """ Override DownloadJob. - ''' + """ return session.query(Message).filter_by(uuid=self.uuid).one() def call_download_api(self, api: API, db_object: Message) -> Tuple[str, str]: - ''' + """ Override DownloadJob. - ''' + """ sdk_object = SdkSubmission(uuid=db_object.uuid) sdk_object.source_uuid = db_object.source.uuid sdk_object.filename = db_object.filename - return api.download_submission(sdk_object, - timeout=self._get_realistic_timeout(db_object.size)) + return api.download_submission( + sdk_object, timeout=self._get_realistic_timeout(db_object.size) + ) def call_decrypt(self, filepath: str, session: Session = None) -> str: - ''' + """ Override DownloadJob. Decrypt the file located at the given filepath and store its plaintext content in the local @@ -319,52 +322,54 @@ def call_decrypt(self, filepath: str, session: Session = None) -> str: The file containing the plaintext should be deleted once the content is stored in the db. The return value is an empty string; messages have no original filename. - ''' - with NamedTemporaryFile('w+') as plaintext_file: + """ + with NamedTemporaryFile("w+") as plaintext_file: try: self.gpg.decrypt_submission_or_reply(filepath, plaintext_file.name, is_doc=False) set_message_or_reply_content( model_type=Message, uuid=self.uuid, session=session, - content=plaintext_file.read()) + content=plaintext_file.read(), + ) finally: try: os.rmdir(os.path.dirname(filepath)) except OSError: - msg = f'Could not delete decryption directory: {os.path.dirname(filepath)}' + msg = f"Could not delete decryption directory: {os.path.dirname(filepath)}" logger.debug(msg) return "" class FileDownloadJob(DownloadJob): - ''' + """ Download and decrypt a file from a source. - ''' + """ def __init__(self, uuid: str, data_dir: str, gpg: GpgHelper) -> None: super().__init__(data_dir, uuid) self.gpg = gpg def get_db_object(self, session: Session) -> File: - ''' + """ Override DownloadJob. - ''' + """ return session.query(File).filter_by(uuid=self.uuid).one() def call_download_api(self, api: API, db_object: File) -> Tuple[str, str]: - ''' + """ Override DownloadJob. - ''' + """ sdk_object = SdkSubmission(uuid=db_object.uuid) sdk_object.source_uuid = db_object.source.uuid sdk_object.filename = db_object.filename - return api.download_submission(sdk_object, - timeout=self._get_realistic_timeout(db_object.size)) + return api.download_submission( + sdk_object, timeout=self._get_realistic_timeout(db_object.size) + ) def call_decrypt(self, filepath: str, session: Session = None) -> str: - ''' + """ Override DownloadJob. Decrypt the file located at the given filepath and store its plaintext content in a file on @@ -372,7 +377,7 @@ def call_decrypt(self, filepath: str, session: Session = None) -> str: The file storing the plaintext should have the same name as the downloaded file but without the file extensions, e.g. 1-impractical_thing-doc.gz.gpg -> 1-impractical_thing-doc - ''' + """ fn_no_ext, _ = os.path.splitext(os.path.splitext(os.path.basename(filepath))[0]) plaintext_filepath = os.path.join(os.path.dirname(filepath), fn_no_ext) original_filename = self.gpg.decrypt_submission_or_reply( diff --git a/securedrop_client/api_jobs/sources.py b/securedrop_client/api_jobs/sources.py index f31cdce00..61c2abbad 100644 --- a/securedrop_client/api_jobs/sources.py +++ b/securedrop_client/api_jobs/sources.py @@ -1,7 +1,7 @@ import logging -import sdclientapi -from sdclientapi import API, ServerConnectionError, RequestTimeoutError +import sdclientapi +from sdclientapi import API, RequestTimeoutError, ServerConnectionError from sqlalchemy.orm.session import Session from securedrop_client.api_jobs.base import ApiJob @@ -15,11 +15,11 @@ def __init__(self, uuid: str) -> None: self.uuid = uuid def call_api(self, api_client: API, session: Session) -> str: - ''' + """ Override ApiJob. Delete a source on the server - ''' + """ try: source_sdk_object = sdclientapi.Source(uuid=self.uuid) api_client.delete_source(source_sdk_object) @@ -29,7 +29,8 @@ def call_api(self, api_client: API, session: Session) -> str: raise except Exception as e: error_message = "Failed to delete source {uuid} due to {exception}".format( - uuid=self.uuid, exception=repr(e)) + uuid=self.uuid, exception=repr(e) + ) raise DeleteSourceJobException(error_message, self.uuid) diff --git a/securedrop_client/api_jobs/sync.py b/securedrop_client/api_jobs/sync.py index 94bfec9b4..50c82cf15 100644 --- a/securedrop_client/api_jobs/sync.py +++ b/securedrop_client/api_jobs/sync.py @@ -1,5 +1,5 @@ -from typing import Any import logging +from typing import Any from sdclientapi import API from sqlalchemy.orm.session import Session @@ -7,14 +7,13 @@ from securedrop_client.api_jobs.base import ApiJob from securedrop_client.storage import get_remote_data, update_local_storage - logger = logging.getLogger(__name__) class MetadataSyncJob(ApiJob): - ''' + """ Update source metadata such that new download jobs can be added to the queue. - ''' + """ NUMBER_OF_TIMES_TO_RETRY_AN_API_CALL = 2 @@ -23,13 +22,13 @@ def __init__(self, data_dir: str) -> None: self.data_dir = data_dir def call_api(self, api_client: API, session: Session) -> Any: - ''' + """ Override ApiJob. Download new metadata, update the local database, import new keys, and then the success signal will let the controller know to add any new download jobs. - ''' + """ # TODO: Once https://github.com/freedomofpress/securedrop-client/issues/648, we will want to # pass the default request timeout to api calls instead of setting it on the api object @@ -40,8 +39,6 @@ def call_api(self, api_client: API, session: Session) -> Any: api_client.default_request_timeout = 60 remote_sources, remote_submissions, remote_replies = get_remote_data(api_client) - update_local_storage(session, - remote_sources, - remote_submissions, - remote_replies, - self.data_dir) + update_local_storage( + session, remote_sources, remote_submissions, remote_replies, self.data_dir + ) diff --git a/securedrop_client/api_jobs/updatestar.py b/securedrop_client/api_jobs/updatestar.py index 48b7c2d27..a25adb40d 100644 --- a/securedrop_client/api_jobs/updatestar.py +++ b/securedrop_client/api_jobs/updatestar.py @@ -1,6 +1,6 @@ import logging -import sdclientapi +import sdclientapi from sdclientapi import API, RequestTimeoutError, ServerConnectionError from sqlalchemy.orm.session import Session @@ -15,11 +15,11 @@ def __init__(self, uuid: str, is_starred: bool) -> None: self.is_starred = is_starred def call_api(self, api_client: API, session: Session) -> str: - ''' + """ Override ApiJob. Star or Unstar an user on the server - ''' + """ try: source_sdk_object = sdclientapi.Source(uuid=self.uuid) @@ -30,10 +30,10 @@ def call_api(self, api_client: API, session: Session) -> str: return self.uuid except (RequestTimeoutError, ServerConnectionError) as e: - error_message = f'Failed to update star on source {self.uuid} due to error: {e}' + error_message = f"Failed to update star on source {self.uuid} due to error: {e}" raise UpdateStarJobTimeoutError(error_message, self.uuid) except Exception as e: - error_message = f'Failed to update star on source {self.uuid} due to {e}' + error_message = f"Failed to update star on source {self.uuid} due to {e}" raise UpdateStarJobError(error_message, self.uuid) diff --git a/securedrop_client/api_jobs/uploads.py b/securedrop_client/api_jobs/uploads.py index 15824508e..4ce61c9f8 100644 --- a/securedrop_client/api_jobs/uploads.py +++ b/securedrop_client/api_jobs/uploads.py @@ -1,9 +1,9 @@ import logging -import sdclientapi +import sdclientapi from sdclientapi import API, RequestTimeoutError, ServerConnectionError -from sqlalchemy.orm.session import Session from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm.session import Session from securedrop_client.api_jobs.base import SingleObjectApiJob from securedrop_client.crypto import GpgHelper @@ -22,35 +22,36 @@ def __init__(self, source_uuid: str, reply_uuid: str, message: str, gpg: GpgHelp self.gpg = gpg def call_api(self, api_client: API, session: Session) -> str: - ''' + """ Override ApiJob. Encrypt the reply and send it to the server. If the call is successful, add it to the local database and return the reply uuid string. Otherwise raise a SendReplyJobException so that we can return the reply uuid. - ''' + """ try: # If the reply has already made it to the server but we didn't get a 201 response back, # then a reply with self.reply_uuid will exist in the replies table. reply_db_object = session.query(Reply).filter_by(uuid=self.reply_uuid).one_or_none() if reply_db_object: - logger.debug('Reply {} has already been sent successfully'.format(self.reply_uuid)) + logger.debug("Reply {} has already been sent successfully".format(self.reply_uuid)) return reply_db_object.uuid # If the draft does not exist because it was deleted locally then do not send the # message to the source. - draft_reply_db_object = session.query(DraftReply).filter_by( - uuid=self.reply_uuid).one_or_none() + draft_reply_db_object = ( + session.query(DraftReply).filter_by(uuid=self.reply_uuid).one_or_none() + ) if not draft_reply_db_object: - raise Exception('Draft reply {} does not exist'.format(self.reply_uuid)) + raise Exception("Draft reply {} does not exist".format(self.reply_uuid)) # If the source was deleted locally then do not send the message and delete the draft. source = session.query(Source).filter_by(uuid=self.source_uuid).one_or_none() if not source: session.delete(draft_reply_db_object) session.commit() - raise Exception('Source {} does not exists'.format(self.source_uuid)) + raise Exception("Source {} does not exists".format(self.source_uuid)) # Send the draft reply to the source encrypted_reply = self.gpg.encrypt_to_source(self.source_uuid, self.message) @@ -58,7 +59,7 @@ def call_api(self, api_client: API, session: Session) -> str: # Create a new reply object with an updated filename and file counter interaction_count = source.interaction_count + 1 - filename = '{}-{}-reply.gpg'.format(interaction_count, source.journalist_designation) + filename = "{}-{}-reply.gpg".format(interaction_count, source.journalist_designation) reply_db_object = Reply( uuid=self.reply_uuid, source_id=source.id, @@ -66,8 +67,9 @@ def call_api(self, api_client: API, session: Session) -> str: journalist_id=api_client.token_journalist_uuid, content=self.message, is_downloaded=True, - is_decrypted=True) - new_file_counter = int(sdk_reply.filename.split('-')[0]) + is_decrypted=True, + ) + new_file_counter = int(sdk_reply.filename.split("-")[0]) reply_db_object.file_counter = new_file_counter reply_db_object.filename = sdk_reply.filename @@ -75,8 +77,12 @@ def call_api(self, api_client: API, session: Session) -> str: draft_file_counter = draft_reply_db_object.file_counter draft_timestamp = draft_reply_db_object.timestamp update_draft_replies( - session, source.id, draft_timestamp, draft_file_counter, new_file_counter, - commit=False + session, + source.id, + draft_timestamp, + draft_file_counter, + new_file_counter, + commit=False, ) # Add reply to replies table and increase the source interaction count by 1 and delete @@ -90,30 +96,42 @@ def call_api(self, api_client: API, session: Session) -> str: return reply_db_object.uuid except (RequestTimeoutError, ServerConnectionError) as e: message = "Failed to send reply for source {id} due to Exception: {error}".format( - id=self.source_uuid, error=e) + id=self.source_uuid, error=e + ) raise SendReplyJobTimeoutError(message, self.reply_uuid) except Exception as e: # Continue to store the draft reply - message = ''' + message = """ Failed to send reply {uuid} for source {id} due to Exception: {error} - '''.format(uuid=self.reply_uuid, id=self.source_uuid, error=e) + """.format( + uuid=self.reply_uuid, id=self.source_uuid, error=e + ) self._set_status_to_failed(session) raise SendReplyJobError(message, self.reply_uuid) def _set_status_to_failed(self, session: Session) -> None: try: # If draft exists, we set it to failed. draft_reply_db_object = session.query(DraftReply).filter_by(uuid=self.reply_uuid).one() - reply_status = session.query(ReplySendStatus).filter_by( - name=ReplySendStatusCodes.FAILED.value).one() + reply_status = ( + session.query(ReplySendStatus) + .filter_by(name=ReplySendStatusCodes.FAILED.value) + .one() + ) draft_reply_db_object.send_status_id = reply_status.id session.add(draft_reply_db_object) session.commit() except SQLAlchemyError as e: - logger.info('SQL error when setting reply {uuid} as failed, skipping: {e}'.format( - uuid=self.reply_uuid, e=e)) + logger.info( + "SQL error when setting reply {uuid} as failed, skipping: {e}".format( + uuid=self.reply_uuid, e=e + ) + ) except Exception as e: - logger.error('Unknown error when setting reply {uuid} as failed, skipping: {e}'.format( - uuid=self.reply_uuid, e=e)) + logger.error( + "Unknown error when setting reply {uuid} as failed, skipping: {e}".format( + uuid=self.reply_uuid, e=e + ) + ) def _make_call(self, encrypted_reply: str, api_client: API) -> sdclientapi.Reply: sdk_source = sdclientapi.Source(uuid=self.source_uuid) diff --git a/securedrop_client/app.py b/securedrop_client/app.py index 363f65895..db7ac6f82 100644 --- a/securedrop_client/app.py +++ b/securedrop_client/app.py @@ -16,32 +16,34 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -import logging -import os import gettext import locale +import logging +import os import platform import signal -import sys import socket +import sys from argparse import ArgumentParser -from PyQt5.QtWidgets import QApplication, QMessageBox +from logging.handlers import SysLogHandler, TimedRotatingFileHandler + from PyQt5.QtCore import Qt, QTimer -from logging.handlers import TimedRotatingFileHandler, SysLogHandler +from PyQt5.QtWidgets import QApplication, QMessageBox + from securedrop_client import __version__ -from securedrop_client.logic import Controller -from securedrop_client.gui.main import Window from securedrop_client.db import make_session_maker +from securedrop_client.gui.main import Window +from securedrop_client.logic import Controller from securedrop_client.utils import safe_mkdir -DEFAULT_SDC_HOME = '~/.securedrop_client' -ENCODING = 'utf-8' -LOGLEVEL = os.environ.get('LOGLEVEL', 'info').upper() +DEFAULT_SDC_HOME = "~/.securedrop_client" +ENCODING = "utf-8" +LOGLEVEL = os.environ.get("LOGLEVEL", "info").upper() def init(sdc_home: str) -> None: safe_mkdir(sdc_home) - safe_mkdir(sdc_home, 'data') + safe_mkdir(sdc_home, "data") def excepthook(*exc_args): @@ -49,30 +51,31 @@ def excepthook(*exc_args): This function is called in the event of a catastrophic failure. Log exception and exit cleanly. """ - logging.error('Unrecoverable error', exc_info=(exc_args)) + logging.error("Unrecoverable error", exc_info=(exc_args)) sys.__excepthook__(*exc_args) - print('') # force terminal prompt on to a new line + print("") # force terminal prompt on to a new line sys.exit(1) def configure_locale_and_language() -> str: # 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')) + 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. if current_locale is None: - language_code = 'en' + language_code = "en" else: language_code = current_locale[:2] except ValueError: # pragma: no cover - language_code = 'en' # pragma: no cover + language_code = "en" # pragma: no cover # DEBUG/TRANSLATE: override the language code here (e.g. to Chinese). # language_code = 'zh' - gettext.translation('securedrop_client', localedir=localedir, - languages=[language_code], fallback=True).install() + gettext.translation( + "securedrop_client", localedir=localedir, languages=[language_code], fallback=True + ).install() return language_code @@ -80,18 +83,17 @@ def configure_logging(sdc_home: str) -> None: """ All logging related settings are set up by this function. """ - safe_mkdir(sdc_home, 'logs') - log_file = os.path.join(sdc_home, 'logs', 'client.log') + safe_mkdir(sdc_home, "logs") + log_file = os.path.join(sdc_home, "logs", "client.log") # set logging format - log_fmt = ('%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) ' - '%(levelname)s: %(message)s') + log_fmt = "%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) " "%(levelname)s: %(message)s" formatter = logging.Formatter(log_fmt) # define log handlers such as for rotating log files - handler = TimedRotatingFileHandler(log_file, when='midnight', - backupCount=5, delay=False, - encoding=ENCODING) + handler = TimedRotatingFileHandler( + log_file, when="midnight", backupCount=5, delay=False, encoding=ENCODING + ) handler.setFormatter(formatter) # For rsyslog handler @@ -124,38 +126,41 @@ def signal_handler(*nargs) -> None: def expand_to_absolute(value: str) -> str: - ''' + """ Helper that expands a path to the absolute path so users can provide arguments in the form ``~/my/dir/``. - ''' + """ return os.path.abspath(os.path.expanduser(value)) def arg_parser() -> ArgumentParser: - parser = ArgumentParser('securedrop-client', - description='SecureDrop Journalist GUI') + parser = ArgumentParser("securedrop-client", description="SecureDrop Journalist GUI") parser.add_argument( - '-H', '--sdc-home', + "-H", + "--sdc-home", default=DEFAULT_SDC_HOME, type=expand_to_absolute, - help=('SecureDrop Client home directory for storing files and state. ' - '(Default {})'.format(DEFAULT_SDC_HOME))) + help=( + "SecureDrop Client home directory for storing files and state. " + "(Default {})".format(DEFAULT_SDC_HOME) + ), + ) parser.add_argument( - '--no-proxy', action='store_true', - help='Use proxy AppVM name to connect to server.') + "--no-proxy", action="store_true", help="Use proxy AppVM name to connect to server." + ) parser.add_argument( - '--no-qubes', action='store_true', - help='Disable opening submissions in DispVMs') + "--no-qubes", action="store_true", help="Disable opening submissions in DispVMs" + ) return parser def prevent_second_instance(app: QApplication, unique_name: str) -> None: # This function is only necessary on Qubes, so we can skip it on other platforms to help devs - if platform.system() != 'Linux': # pragma: no cover + if platform.system() != "Linux": # pragma: no cover return # Null byte triggers abstract namespace - IDENTIFIER = '\0' + app.applicationName() + unique_name + IDENTIFIER = "\0" + app.applicationName() + unique_name ALREADY_BOUND_ERRNO = 98 app.instance_binding = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) @@ -164,7 +169,7 @@ def prevent_second_instance(app: QApplication, unique_name: str) -> None: except OSError as e: if e.errno == ALREADY_BOUND_ERRNO: err_dialog = QMessageBox() - err_dialog.setText(app.applicationName() + ' is already running.') + err_dialog.setText(app.applicationName() + " is already running.") err_dialog.exec() sys.exit() else: @@ -188,11 +193,11 @@ def start_app(args, qt_args) -> None: configure_locale_and_language() init(args.sdc_home) configure_logging(args.sdc_home) - logging.info('Starting SecureDrop Client {}'.format(__version__)) + logging.info("Starting SecureDrop Client {}".format(__version__)) app = QApplication(qt_args) - app.setApplicationName('SecureDrop Client') - app.setDesktopFileName('org.freedomofthepress.securedrop.client') + app.setApplicationName("SecureDrop Client") + app.setDesktopFileName("org.freedomofthepress.securedrop.client") app.setApplicationVersion(__version__) app.setAttribute(Qt.AA_UseHighDpiPixmaps) @@ -202,8 +207,14 @@ def start_app(args, qt_args) -> None: gui = Window() - controller = Controller("http://localhost:8081/", gui, session_maker, - args.sdc_home, not args.no_proxy, not args.no_qubes) + controller = Controller( + "http://localhost:8081/", + gui, + session_maker, + args.sdc_home, + not args.no_proxy, + not args.no_qubes, + ) controller.setup() configure_signal_handlers(app) @@ -217,5 +228,5 @@ def start_app(args, qt_args) -> None: def run() -> None: args, qt_args = arg_parser().parse_known_args() # reinsert the program's name - qt_args.insert(0, 'securedrop-client') + qt_args.insert(0, "securedrop-client") start_app(args, qt_args) diff --git a/securedrop_client/config.py b/securedrop_client/config.py index efc5fb37d..b4166f2c6 100644 --- a/securedrop_client/config.py +++ b/securedrop_client/config.py @@ -1,17 +1,17 @@ import json import logging -from typing import TypeVar, Type import os +from typing import Type, TypeVar logger = logging.getLogger(__name__) # See: https://mypy.readthedocs.io/en/stable/generics.html#generic-methods-and-generic-self -T = TypeVar('T', bound='Config') +T = TypeVar("T", bound="Config") class Config: - CONFIG_NAME = 'config.json' + CONFIG_NAME = "config.json" def __init__(self, journalist_key_fingerprint: str) -> None: self.journalist_key_fingerprint = journalist_key_fingerprint @@ -24,12 +24,10 @@ def from_home_dir(cls: Type[T], sdc_home: str) -> T: with open(full_path) as f: json_config = json.loads(f.read()) except Exception as e: - logger.error('Error opening config file at {}: {}'.format(full_path, e)) + logger.error("Error opening config file at {}: {}".format(full_path, e)) json_config = {} - return cls( - journalist_key_fingerprint=json_config.get('journalist_key_fingerprint', None), - ) + return cls(journalist_key_fingerprint=json_config.get("journalist_key_fingerprint", None),) @property def is_valid(self) -> bool: diff --git a/securedrop_client/crypto.py b/securedrop_client/crypto.py index c8e51b6bb..7889d86e4 100644 --- a/securedrop_client/crypto.py +++ b/securedrop_client/crypto.py @@ -59,7 +59,7 @@ def read_gzip_header_filename(filename: str) -> str: raise OSError("Unknown compression method") if gzip_header_flags & GZIP_FLAG_EXTRA_FIELDS: - extra_len, = struct.unpack(" str: class GpgHelper: def __init__(self, sdc_home: str, session_maker: scoped_session, is_qubes: bool) -> None: - ''' + """ :param sdc_home: Home directory for the SecureDrop client :param is_qubes: Whether the client is running in Qubes or not - ''' + """ safe_mkdir(os.path.join(sdc_home), "gpg") self.sdc_home = sdc_home self.is_qubes = is_qubes @@ -88,10 +88,9 @@ def __init__(self, sdc_home: str, session_maker: scoped_session, is_qubes: bool) config = Config.from_home_dir(self.sdc_home) self.journalist_key_fingerprint = config.journalist_key_fingerprint - def decrypt_submission_or_reply(self, - filepath: str, - plaintext_filepath: str, - is_doc: bool = False) -> str: + def decrypt_submission_or_reply( + self, filepath: str, plaintext_filepath: str, is_doc: bool = False + ) -> str: original_filename, _ = os.path.splitext(os.path.splitext(os.path.basename(filepath))[0]) @@ -124,10 +123,9 @@ def decrypt_submission_or_reply(self, if is_doc: original_filename = read_gzip_header_filename(out.name) or plaintext_filepath decrypt_path = os.path.join( - os.path.dirname(filepath), - os.path.basename(original_filename) + os.path.dirname(filepath), os.path.basename(original_filename) ) - with gzip.open(out.name, 'rb') as infile, open(decrypt_path, 'wb') as outfile: + with gzip.open(out.name, "rb") as infile, open(decrypt_path, "wb") as outfile: shutil.copyfileobj(infile, outfile) else: shutil.copy(out.name, plaintext_filepath) @@ -140,7 +138,7 @@ def _gpg_cmd_base(self) -> list: else: cmd = ["gpg", "--homedir", os.path.join(self.sdc_home, "gpg")] - cmd.extend(['--trust-model', 'always']) + cmd.extend(["--trust-model", "always"]) return cmd def import_key(self, source: Source) -> None: @@ -155,39 +153,39 @@ def import_key(self, source: Source) -> None: def _import(self, key_data: str) -> None: """Imports a key to the client GnuPG keyring.""" - with tempfile.NamedTemporaryFile('w+') as temp_key, \ - tempfile.NamedTemporaryFile('w+') as stdout, \ - tempfile.NamedTemporaryFile('w+') as stderr: + with tempfile.NamedTemporaryFile("w+") as temp_key, tempfile.NamedTemporaryFile( + "w+" + ) as stdout, tempfile.NamedTemporaryFile("w+") as stderr: temp_key.write(key_data) temp_key.seek(0) if self.is_qubes: # pragma: no cover - cmd = ['qubes-gpg-import-key', temp_key.name] + cmd = ["qubes-gpg-import-key", temp_key.name] else: cmd = self._gpg_cmd_base() - cmd.extend(['--import-options', 'import-show', - '--with-colons', '--import', - temp_key.name]) + cmd.extend( + ["--import-options", "import-show", "--with-colons", "--import", temp_key.name] + ) try: subprocess.check_call(cmd, stdout=stdout, stderr=stderr) except subprocess.CalledProcessError as e: stderr.seek(0) - raise CryptoError('Could not import key: {}\n{}'.format(e, stderr.read())) + raise CryptoError("Could not import key: {}\n{}".format(e, stderr.read())) def encrypt_to_source(self, source_uuid: str, data: str) -> str: - ''' + """ :param data: A string of data to encrypt to a source. - ''' + """ session = self.session_maker() source = session.query(Source).filter_by(uuid=source_uuid).one() # do not attempt to encrypt if the journalist key is missing if not self.journalist_key_fingerprint: - raise CryptoError('Could not encrypt reply due to missing fingerprint for journalist') + raise CryptoError("Could not encrypt reply due to missing fingerprint for journalist") # do not attempt to encrypt if the source key is missing if not (source.fingerprint and source.public_key): - raise CryptoError(f'Could not encrypt reply: no key for source {source_uuid}') + raise CryptoError(f"Could not encrypt reply: no key for source {source_uuid}") try: self.import_key(source) @@ -196,22 +194,28 @@ def encrypt_to_source(self, source_uuid: str, data: str) -> str: cmd = self._gpg_cmd_base() - with tempfile.NamedTemporaryFile('w+') as content, \ - tempfile.NamedTemporaryFile('w+') as stdout, \ - tempfile.NamedTemporaryFile('w+') as stderr: + with tempfile.NamedTemporaryFile("w+") as content, tempfile.NamedTemporaryFile( + "w+" + ) as stdout, tempfile.NamedTemporaryFile("w+") as stderr: content.write(data) content.seek(0) - cmd.extend(['--encrypt', - '-r', source.fingerprint, - '-r', self.journalist_key_fingerprint, - '--armor']) + cmd.extend( + [ + "--encrypt", + "-r", + source.fingerprint, + "-r", + self.journalist_key_fingerprint, + "--armor", + ] + ) if not self.is_qubes: # In Qubes, the ciphertext will go to stdout. # In addition the option below cannot be passed # through the gpg client wrapper. - cmd.extend(['-o-']) # write to stdout + cmd.extend(["-o-"]) # write to stdout cmd.extend([content.name]) try: @@ -219,7 +223,7 @@ def encrypt_to_source(self, source_uuid: str, data: str) -> str: except subprocess.CalledProcessError as e: stderr.seek(0) err = stderr.read() - raise CryptoError(f'Could not encrypt to source {source_uuid}: {e}\n{err}') + raise CryptoError(f"Could not encrypt to source {source_uuid}: {e}\n{err}") stdout.seek(0) return stdout.read() diff --git a/securedrop_client/db.py b/securedrop_client/db.py index 780556f90..c766aee10 100644 --- a/securedrop_client/db.py +++ b/securedrop_client/db.py @@ -1,21 +1,31 @@ import datetime -from enum import Enum import os - +from enum import Enum from typing import Any, List, Union # noqa: F401 -from sqlalchemy import Boolean, Column, create_engine, DateTime, ForeignKey, Integer, String, \ - Text, MetaData, CheckConstraint, text, UniqueConstraint +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + String, + Text, + UniqueConstraint, + create_engine, + text, +) from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship, backref, scoped_session, sessionmaker - +from sqlalchemy.orm import backref, relationship, scoped_session, sessionmaker convention = { - "ix": 'ix_%(column_0_label)s', + "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", "ck": "ck_%(table_name)s_%(column_0_name)s", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", - "pk": "pk_%(table_name)s" + "pk": "pk_%(table_name)s", } metadata = MetaData(naming_convention=convention) @@ -24,29 +34,29 @@ def make_session_maker(home: str) -> scoped_session: - db_path = os.path.join(home, 'svs.sqlite') - engine = create_engine('sqlite:///{}'.format(db_path)) + db_path = os.path.join(home, "svs.sqlite") + engine = create_engine("sqlite:///{}".format(db_path)) maker = sessionmaker(bind=engine) return scoped_session(maker) class Source(Base): - __tablename__ = 'sources' + __tablename__ = "sources" id = Column(Integer, primary_key=True) uuid = Column(String(36), unique=True, nullable=False) journalist_designation = Column(String(255), nullable=False) document_count = Column(Integer, server_default=text("0"), nullable=False) - is_flagged = Column(Boolean(name='is_flagged'), server_default=text("0")) + is_flagged = Column(Boolean(name="is_flagged"), server_default=text("0")) public_key = Column(Text, nullable=True) fingerprint = Column(String(64)) interaction_count = Column(Integer, server_default=text("0"), nullable=False) - is_starred = Column(Boolean(name='is_starred'), server_default=text("0")) + is_starred = Column(Boolean(name="is_starred"), server_default=text("0")) last_updated = Column(DateTime) def __repr__(self) -> str: - return ''.format(self.uuid, self.journalist_designation) + return "".format(self.uuid, self.journalist_designation) @property def collection(self) -> List: @@ -58,9 +68,12 @@ def collection(self) -> List: collection.extend(self.replies) collection.extend(self.draftreplies) # Sort first by the file_counter, then by timestamp (used only for draft replies). - collection.sort(key=lambda x: (x.file_counter, - getattr(x, "timestamp", - datetime.datetime(datetime.MINYEAR, 1, 1)))) + collection.sort( + key=lambda x: ( + x.file_counter, + getattr(x, "timestamp", datetime.datetime(datetime.MINYEAR, 1, 1)), + ) + ) return collection @property @@ -77,16 +90,17 @@ def server_collection(self) -> List: @property def journalist_filename(self) -> str: - valid_chars = 'abcdefghijklmnopqrstuvwxyz1234567890-_' - return ''.join([c for c in self.journalist_designation.lower().replace( - ' ', '_') if c in valid_chars]) + valid_chars = "abcdefghijklmnopqrstuvwxyz1234567890-_" + return "".join( + [c for c in self.journalist_designation.lower().replace(" ", "_") if c in valid_chars] + ) class Message(Base): - __tablename__ = 'messages' + __tablename__ = "messages" __table_args__ = ( - UniqueConstraint('source_id', 'file_counter', name='uq_messages_source_id_file_counter'), + UniqueConstraint("source_id", "file_counter", name="uq_messages_source_id_file_counter"), ) id = Column(Integer, primary_key=True) @@ -97,37 +111,37 @@ class Message(Base): download_url = Column(String(255), nullable=False) # This is whether the submission has been downloaded in the local database. - is_downloaded = Column(Boolean(name='is_downloaded'), nullable=False, server_default=text("0")) + is_downloaded = Column(Boolean(name="is_downloaded"), nullable=False, server_default=text("0")) # This tracks if the file had been successfully decrypted after download. is_decrypted = Column( - Boolean(name='is_decrypted'), - CheckConstraint('CASE WHEN is_downloaded = 0 THEN is_decrypted IS NULL ELSE 1 END', - name='messages_compare_is_downloaded_vs_is_decrypted'), + Boolean(name="is_decrypted"), + CheckConstraint( + "CASE WHEN is_downloaded = 0 THEN is_decrypted IS NULL ELSE 1 END", + name="messages_compare_is_downloaded_vs_is_decrypted", + ), nullable=True, ) - download_error_id = Column( - Integer, - ForeignKey('downloaderrors.id') - ) + download_error_id = Column(Integer, ForeignKey("downloaderrors.id")) download_error = relationship("DownloadError") # This reflects read status stored on the server. - is_read = Column(Boolean(name='is_read'), nullable=False, server_default=text("0")) + is_read = Column(Boolean(name="is_read"), nullable=False, server_default=text("0")) content = Column( Text, # this check contraint ensures the state of the DB is what one would expect - CheckConstraint('CASE WHEN is_downloaded = 0 THEN content IS NULL ELSE 1 END', - name='ck_message_compare_download_vs_content') + CheckConstraint( + "CASE WHEN is_downloaded = 0 THEN content IS NULL ELSE 1 END", + name="ck_message_compare_download_vs_content", + ), ) - source_id = Column(Integer, ForeignKey('sources.id'), nullable=False) - source = relationship("Source", - backref=backref("messages", order_by=id, - cascade="delete"), - lazy="joined") + source_id = Column(Integer, ForeignKey("sources.id"), nullable=False) + source = relationship( + "Source", backref=backref("messages", order_by=id, cascade="delete"), lazy="joined" + ) last_updated = Column( DateTime, @@ -137,10 +151,10 @@ class Message(Base): ) def __init__(self, **kwargs: Any) -> None: - if 'file_counter' in kwargs: - raise TypeError('Cannot manually set file_counter') - filename = kwargs['filename'] - kwargs['file_counter'] = int(filename.split('-')[0]) + if "file_counter" in kwargs: + raise TypeError("Cannot manually set file_counter") + filename = kwargs["filename"] + kwargs["file_counter"] = int(filename.split("-")[0]) super().__init__(**kwargs) def __str__(self) -> str: @@ -152,29 +166,29 @@ def __str__(self) -> str: else: if self.download_error is not None: return self.download_error.explain(self.__class__.__name__) - return '' + return "" def __repr__(self) -> str: - return ''.format(self.uuid, self.filename) + return "".format(self.uuid, self.filename) def location(self, data_dir: str) -> str: - ''' + """ Return the full path to the Message's file. - ''' + """ return os.path.abspath( os.path.join( data_dir, self.source.journalist_filename, - os.path.splitext(self.filename)[0] + '.txt' + os.path.splitext(self.filename)[0] + ".txt", ) ) class File(Base): - __tablename__ = 'files' + __tablename__ = "files" __table_args__ = ( - UniqueConstraint('source_id', 'file_counter', name='uq_messages_source_id_file_counter'), + UniqueConstraint("source_id", "file_counter", name="uq_messages_source_id_file_counter"), ) id = Column(Integer, primary_key=True) @@ -186,30 +200,28 @@ class File(Base): download_url = Column(String(255), nullable=False) # This is whether the submission has been downloaded in the local database. - is_downloaded = Column(Boolean(name='is_downloaded'), nullable=False, server_default=text("0")) + is_downloaded = Column(Boolean(name="is_downloaded"), nullable=False, server_default=text("0")) # This tracks if the file had been successfully decrypted after download. is_decrypted = Column( - Boolean(name='is_decrypted'), - CheckConstraint('CASE WHEN is_downloaded = 0 THEN is_decrypted IS NULL ELSE 1 END', - name='files_compare_is_downloaded_vs_is_decrypted'), + Boolean(name="is_decrypted"), + CheckConstraint( + "CASE WHEN is_downloaded = 0 THEN is_decrypted IS NULL ELSE 1 END", + name="files_compare_is_downloaded_vs_is_decrypted", + ), nullable=True, ) - download_error_id = Column( - Integer, - ForeignKey('downloaderrors.id') - ) + download_error_id = Column(Integer, ForeignKey("downloaderrors.id")) download_error = relationship("DownloadError") # This reflects read status stored on the server. - is_read = Column(Boolean(name='is_read'), nullable=False, server_default=text("0")) + is_read = Column(Boolean(name="is_read"), nullable=False, server_default=text("0")) - source_id = Column(Integer, ForeignKey('sources.id'), nullable=False) - source = relationship("Source", - backref=backref("files", order_by=id, - cascade="delete"), - lazy="joined") + source_id = Column(Integer, ForeignKey("sources.id"), nullable=False) + source = relationship( + "Source", backref=backref("files", order_by=id, cascade="delete"), lazy="joined" + ) last_updated = Column( DateTime, @@ -219,10 +231,10 @@ class File(Base): ) def __init__(self, **kwargs: Any) -> None: - if 'file_counter' in kwargs: - raise TypeError('Cannot manually set file_counter') - filename = kwargs['filename'] - kwargs['file_counter'] = int(filename.split('-')[0]) + if "file_counter" in kwargs: + raise TypeError("Cannot manually set file_counter") + filename = kwargs["filename"] + kwargs["file_counter"] = int(filename.split("-")[0]) super().__init__(**kwargs) def __str__(self) -> str: @@ -234,70 +246,68 @@ def __str__(self) -> str: return self.download_error.explain(self.__class__.__name__) return "File: {}".format(self.filename) else: - return '' + return "" def __repr__(self) -> str: - return ''.format(self.uuid) + return "".format(self.uuid) def location(self, data_dir: str) -> str: - ''' + """ Return the full path to the File's file. - ''' + """ return os.path.abspath( os.path.join( data_dir, self.source.journalist_filename, - '{}-{}-doc'.format(self.file_counter, self.source.journalist_filename), - self.filename + "{}-{}-doc".format(self.file_counter, self.source.journalist_filename), + self.filename, ) ) class Reply(Base): - __tablename__ = 'replies' + __tablename__ = "replies" __table_args__ = ( - UniqueConstraint('source_id', 'file_counter', name='uq_messages_source_id_file_counter'), + UniqueConstraint("source_id", "file_counter", name="uq_messages_source_id_file_counter"), ) id = Column(Integer, primary_key=True) uuid = Column(String(36), unique=True, nullable=False) - source_id = Column(Integer, ForeignKey('sources.id'), nullable=False) - source = relationship("Source", - backref=backref("replies", order_by=id, - cascade="delete"), - lazy="joined") + source_id = Column(Integer, ForeignKey("sources.id"), nullable=False) + source = relationship( + "Source", backref=backref("replies", order_by=id, cascade="delete"), lazy="joined" + ) - journalist_id = Column(Integer, ForeignKey('users.id')) - journalist = relationship( - "User", backref=backref('replies', order_by=id)) + journalist_id = Column(Integer, ForeignKey("users.id")) + journalist = relationship("User", backref=backref("replies", order_by=id)) filename = Column(String(255), nullable=False) file_counter = Column(Integer, nullable=False) size = Column(Integer) # This is whether the reply has been downloaded in the local database. - is_downloaded = Column(Boolean(name='is_downloaded'), - default=False) + is_downloaded = Column(Boolean(name="is_downloaded"), default=False) content = Column( Text, - CheckConstraint('CASE WHEN is_downloaded = 0 THEN content IS NULL ELSE 1 END', - name='replies_compare_download_vs_content') + CheckConstraint( + "CASE WHEN is_downloaded = 0 THEN content IS NULL ELSE 1 END", + name="replies_compare_download_vs_content", + ), ) # This tracks if the file had been successfully decrypted after download. is_decrypted = Column( - Boolean(name='is_decrypted'), - CheckConstraint('CASE WHEN is_downloaded = 0 THEN is_decrypted IS NULL ELSE 1 END', - name='replies_compare_is_downloaded_vs_is_decrypted'), + Boolean(name="is_decrypted"), + CheckConstraint( + "CASE WHEN is_downloaded = 0 THEN is_decrypted IS NULL ELSE 1 END", + name="replies_compare_is_downloaded_vs_is_decrypted", + ), nullable=True, ) - download_error_id = Column( - Integer, - ForeignKey('downloaderrors.id') - ) + download_error_id = Column(Integer, ForeignKey("downloaderrors.id")) download_error = relationship("DownloadError") last_updated = Column( @@ -308,10 +318,10 @@ class Reply(Base): ) def __init__(self, **kwargs: Any) -> None: - if 'file_counter' in kwargs: - raise TypeError('Cannot manually set file_counter') - filename = kwargs['filename'] - kwargs['file_counter'] = int(filename.split('-')[0]) + if "file_counter" in kwargs: + raise TypeError("Cannot manually set file_counter") + filename = kwargs["filename"] + kwargs["file_counter"] = int(filename.split("-")[0]) super().__init__(**kwargs) def __str__(self) -> str: @@ -323,20 +333,20 @@ def __str__(self) -> str: else: if self.download_error is not None: return self.download_error.explain(self.__class__.__name__) - return '' + return "" def __repr__(self) -> str: - return ''.format(self.uuid, self.filename) + return "".format(self.uuid, self.filename) def location(self, data_dir: str) -> str: - ''' + """ Return the full path to the Reply's file. - ''' + """ return os.path.abspath( os.path.join( data_dir, self.source.journalist_filename, - os.path.splitext(self.filename)[0] + '.txt' + os.path.splitext(self.filename)[0] + ".txt", ) ) @@ -348,6 +358,7 @@ class DownloadErrorCodes(Enum): The templates are intended to be formatted with the class name of a downloadable item. """ + CHECKSUM_ERROR = "cannot download {object_type}" DECRYPTION_ERROR = "cannot decrypt {object_type}" @@ -356,7 +367,8 @@ class DownloadError(Base): """ Table of errors that can occur with downloadable items: File, Message, Reply. """ - __tablename__ = 'downloaderrors' + + __tablename__ = "downloaderrors" id = Column(Integer, primary_key=True) name = Column(String(36), unique=True, nullable=False) @@ -377,19 +389,17 @@ def explain(self, classname: str) -> str: class DraftReply(Base): - __tablename__ = 'draftreplies' + __tablename__ = "draftreplies" id = Column(Integer, primary_key=True) uuid = Column(String(36), unique=True, nullable=False) timestamp = Column(DateTime, nullable=False) - source_id = Column(Integer, ForeignKey('sources.id'), nullable=False) - source = relationship("Source", - backref=backref("draftreplies", order_by=id, - cascade="delete"), - lazy="joined") - journalist_id = Column(Integer, ForeignKey('users.id')) - journalist = relationship( - "User", backref=backref('draftreplies', order_by=id)) + source_id = Column(Integer, ForeignKey("sources.id"), nullable=False) + source = relationship( + "Source", backref=backref("draftreplies", order_by=id, cascade="delete"), lazy="joined" + ) + journalist_id = Column(Integer, ForeignKey("users.id")) + journalist = relationship("User", backref=backref("draftreplies", order_by=id)) # Tracks where in this conversation the draft reply was sent. # This points to the file_counter of the previous conversation item. @@ -397,10 +407,7 @@ class DraftReply(Base): content = Column(Text) # This tracks the sending status of the reply. - send_status_id = Column( - Integer, - ForeignKey('replysendstatuses.id') - ) + send_status_id = Column(Integer, ForeignKey("replysendstatuses.id")) send_status = relationship("ReplySendStatus") def __init__(self, **kwargs: Any) -> None: @@ -413,15 +420,15 @@ def __str__(self) -> str: if self.content is not None: return self.content else: - return '' + return "" def __repr__(self) -> str: - return ''.format(self.uuid) + return "".format(self.uuid) class ReplySendStatus(Base): - __tablename__ = 'replysendstatuses' + __tablename__ = "replysendstatuses" id = Column(Integer, primary_key=True) name = Column(String(36), unique=True, nullable=False) @@ -431,18 +438,19 @@ def __init__(self, name: str) -> None: self.name = name def __repr__(self) -> str: - return ''.format(self.name) + return "".format(self.name) class ReplySendStatusCodes(Enum): """In progress (sending) replies can currently have the following statuses""" - PENDING = 'PENDING' - FAILED = 'FAILED' + + PENDING = "PENDING" + FAILED = "FAILED" class User(Base): - __tablename__ = 'users' + __tablename__ = "users" id = Column(Integer, primary_key=True) uuid = Column(String(36), unique=True, nullable=False) @@ -456,7 +464,7 @@ def __repr__(self) -> str: @property def fullname(self) -> str: if self.firstname and self.lastname: - return self.firstname + ' ' + self.lastname + return self.firstname + " " + self.lastname elif self.firstname: return self.firstname elif self.lastname: diff --git a/securedrop_client/export.py b/securedrop_client/export.py index a19007f06..77de69f83 100644 --- a/securedrop_client/export.py +++ b/securedrop_client/export.py @@ -4,15 +4,14 @@ import subprocess import tarfile import threading - -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, Qt - from enum import Enum from io import BytesIO from shlex import quote from tempfile import TemporaryDirectory from typing import List +from PyQt5.QtCore import QObject, Qt, pyqtSignal, pyqtSlot + logger = logging.getLogger(__name__) @@ -23,60 +22,51 @@ def __init__(self, status: str): class ExportStatus(Enum): # On the way to success - USB_CONNECTED = 'USB_CONNECTED' - DISK_ENCRYPTED = 'USB_ENCRYPTED' + USB_CONNECTED = "USB_CONNECTED" + DISK_ENCRYPTED = "USB_ENCRYPTED" # Not too far from success - USB_NOT_CONNECTED = 'USB_NOT_CONNECTED' - BAD_PASSPHRASE = 'USB_BAD_PASSPHRASE' + USB_NOT_CONNECTED = "USB_NOT_CONNECTED" + BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" # Failure - CALLED_PROCESS_ERROR = 'CALLED_PROCESS_ERROR' - DISK_ENCRYPTION_NOT_SUPPORTED_ERROR = 'USB_ENCRYPTION_NOT_SUPPORTED' - ERROR_USB_CONFIGURATION = 'ERROR_USB_CONFIGURATION' - UNEXPECTED_RETURN_STATUS = 'UNEXPECTED_RETURN_STATUS' - PRINTER_NOT_FOUND = 'ERROR_PRINTER_NOT_FOUND' - MISSING_PRINTER_URI = 'ERROR_MISSING_PRINTER_URI' + CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" + DISK_ENCRYPTION_NOT_SUPPORTED_ERROR = "USB_ENCRYPTION_NOT_SUPPORTED" + ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" + UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" + PRINTER_NOT_FOUND = "ERROR_PRINTER_NOT_FOUND" + MISSING_PRINTER_URI = "ERROR_MISSING_PRINTER_URI" class Export(QObject): - ''' + """ This class sends files over to the Export VM so that they can be copied to a luks-encrypted USB disk drive or printed by a USB-connected printer. Files are archived in a specified format, which you can learn more about in the README for the securedrop-export repository. - ''' + """ - METADATA_FN = 'metadata.json' + METADATA_FN = "metadata.json" - USB_TEST_FN = 'usb-test.sd-export' - USB_TEST_METADATA = { - 'device': 'usb-test' - } + USB_TEST_FN = "usb-test.sd-export" + USB_TEST_METADATA = {"device": "usb-test"} - PRINTER_PREFLIGHT_FN = 'printer-preflight.sd-export' - PRINTER_PREFLIGHT_METADATA = { - 'device': 'printer-preflight' - } + PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" + PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} - DISK_TEST_FN = 'disk-test.sd-export' - DISK_TEST_METADATA = { - 'device': 'disk-test' - } + DISK_TEST_FN = "disk-test.sd-export" + DISK_TEST_METADATA = {"device": "disk-test"} - PRINT_FN = 'print_archive.sd-export' + PRINT_FN = "print_archive.sd-export" PRINT_METADATA = { - 'device': 'printer', + "device": "printer", } - DISK_FN = 'archive.sd-export' - DISK_METADATA = { - 'device': 'disk', - 'encryption_method': 'luks' - } - DISK_ENCRYPTION_KEY_NAME = 'encryption_key' - DISK_EXPORT_DIR = 'export_data' + DISK_FN = "archive.sd-export" + DISK_METADATA = {"device": "disk", "encryption_method": "luks"} + DISK_ENCRYPTION_KEY_NAME = "encryption_key" + DISK_EXPORT_DIR = "export_data" # Set up signals for communication with the GUI thread begin_preflight_check = pyqtSignal() @@ -103,7 +93,7 @@ def __init__(self) -> None: self.begin_printer_preflight.connect(self.run_printer_preflight, type=Qt.QueuedConnection) def _export_archive(cls, archive_path: str) -> str: - ''' + """ Make the subprocess call to send the archive to the Export VM, where the archive will be processed. @@ -117,7 +107,7 @@ def _export_archive(cls, archive_path: str) -> str: ExportError: Raised if (1) CalledProcessError is encountered, which can occur when trying to start the Export VM when the USB device is not attached, or (2) when the return code from `check_output` is not 0. - ''' + """ try: # There are already talks of switching to a QVM-RPC implementation for unlocking devices # and exporting files, so it's important to remember to shell-escape what we pass to the @@ -125,14 +115,10 @@ def _export_archive(cls, archive_path: str) -> str: # Python's implementation of subprocess, see # https://docs.python.org/3/library/subprocess.html#security-considerations output = subprocess.check_output( - [ - quote('qvm-open-in-vm'), - quote('sd-devices'), - quote(archive_path), - '--view-only' - ], - stderr=subprocess.STDOUT) - return output.decode('utf-8').strip() + [quote("qvm-open-in-vm"), quote("sd-devices"), quote(archive_path), "--view-only"], + stderr=subprocess.STDOUT, + ) + return output.decode("utf-8").strip() except subprocess.CalledProcessError as e: logger.error(e) raise ExportError(ExportStatus.CALLED_PROCESS_ERROR.value) @@ -140,7 +126,7 @@ def _export_archive(cls, archive_path: str) -> str: def _create_archive( cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] ) -> str: - ''' + """ Create the archive to be sent to the Export VM. Args: @@ -151,10 +137,10 @@ def _create_archive( Returns: str: The path to newly-created archive file. - ''' + """ archive_path = os.path.join(archive_dir, archive_fn) - with tarfile.open(archive_path, 'w:gz') as archive: + with tarfile.open(archive_path, "w:gz") as archive: cls._add_virtual_file_to_archive(archive, cls.METADATA_FN, metadata) for filepath in filepaths: @@ -165,7 +151,7 @@ def _create_archive( def _add_virtual_file_to_archive( cls, archive: tarfile.TarFile, filename: str, filedata: dict ) -> None: - ''' + """ Add filedata to a stream of in-memory bytes and add these bytes to the archive. Args: @@ -173,39 +159,40 @@ def _add_virtual_file_to_archive( filename (str): The name of the virtual file. filedata (dict): The data to add to the bytes stream. - ''' + """ filedata_string = json.dumps(filedata) - filedata_bytes = BytesIO(filedata_string.encode('utf-8')) + filedata_bytes = BytesIO(filedata_string.encode("utf-8")) tarinfo = tarfile.TarInfo(filename) tarinfo.size = len(filedata_string) archive.addfile(tarinfo, filedata_bytes) def _add_file_to_archive(cls, archive: tarfile.TarFile, filepath: str) -> None: - ''' + """ Add the file to the archive. When the archive is extracted, the file should exist in a directory called "export_data". Args: archive: The archive object ot add the file to. filepath: The path to the file that will be added to the supplied archive. - ''' + """ filename = os.path.basename(filepath) arcname = os.path.join(cls.DISK_EXPORT_DIR, filename) archive.add(filepath, arcname=arcname, recursive=False) def _run_printer_preflight(self, archive_dir: str) -> None: - ''' + """ Make sure printer is ready. - ''' + """ archive_path = self._create_archive( - archive_dir, self.PRINTER_PREFLIGHT_FN, self.PRINTER_PREFLIGHT_METADATA) + archive_dir, self.PRINTER_PREFLIGHT_FN, self.PRINTER_PREFLIGHT_METADATA + ) status = self._export_archive(archive_path) if status: raise ExportError(status) def _run_usb_test(self, archive_dir: str) -> None: - ''' + """ Run usb-test. Args: @@ -213,14 +200,14 @@ def _run_usb_test(self, archive_dir: str) -> None: Raises: ExportError: Raised if the usb-test does not return a USB_CONNECTED status. - ''' + """ archive_path = self._create_archive(archive_dir, self.USB_TEST_FN, self.USB_TEST_METADATA) status = self._export_archive(archive_path) if status != ExportStatus.USB_CONNECTED.value: raise ExportError(status) def _run_disk_test(self, archive_dir: str) -> None: - ''' + """ Run disk-test. Args: @@ -228,7 +215,7 @@ def _run_disk_test(self, archive_dir: str) -> None: Raises: ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. - ''' + """ archive_path = self._create_archive(archive_dir, self.DISK_TEST_FN, self.DISK_TEST_METADATA) status = self._export_archive(archive_path) @@ -236,7 +223,7 @@ def _run_disk_test(self, archive_dir: str) -> None: raise ExportError(status) def _run_disk_export(self, archive_dir: str, filepaths: List[str], passphrase: str) -> None: - ''' + """ Run disk-test. Args: @@ -244,7 +231,7 @@ def _run_disk_export(self, archive_dir: str, filepaths: List[str], passphrase: s Raises: ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. - ''' + """ metadata = self.DISK_METADATA.copy() metadata[self.DISK_ENCRYPTION_KEY_NAME] = passphrase archive_path = self._create_archive(archive_dir, self.DISK_FN, metadata, filepaths) @@ -254,13 +241,13 @@ def _run_disk_export(self, archive_dir: str, filepaths: List[str], passphrase: s raise ExportError(status) def _run_print(self, archive_dir: str, filepaths: List[str]) -> None: - ''' + """ Create "printer" archive to send to Export VM. Args: archive_dir (str): The path to the directory in which to create the archive. - ''' + """ metadata = self.PRINT_METADATA.copy() archive_path = self._create_archive(archive_dir, self.PRINT_FN, metadata, filepaths) status = self._export_archive(archive_path) @@ -269,26 +256,29 @@ def _run_print(self, archive_dir: str, filepaths: List[str]) -> None: @pyqtSlot() def run_preflight_checks(self) -> None: - ''' + """ Run preflight checks to verify that the usb device is connected and luks-encrypted. - ''' + """ with TemporaryDirectory() as temp_dir: try: - logger.debug('beginning preflight checks in thread {}'.format( - threading.current_thread().ident)) + logger.debug( + "beginning preflight checks in thread {}".format( + threading.current_thread().ident + ) + ) self._run_usb_test(temp_dir) self._run_disk_test(temp_dir) - logger.debug('completed preflight checks: success') + logger.debug("completed preflight checks: success") self.preflight_check_call_success.emit() except ExportError as e: - logger.debug('completed preflight checks: failure') + logger.debug("completed preflight checks: failure") self.preflight_check_call_failure.emit(e) @pyqtSlot() def run_printer_preflight(self) -> None: - ''' + """ Make sure the Export VM is started. - ''' + """ with TemporaryDirectory() as temp_dir: try: self._run_printer_preflight(temp_dir) @@ -299,20 +289,21 @@ def run_printer_preflight(self) -> None: @pyqtSlot(list, str) def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None: - ''' + """ Export the file to the luks-encrypted usb disk drive attached to the Export VM. Args: filepath: The path of file to export. passphrase: The passphrase to unlock the luks-encrypted usb disk drive. - ''' + """ with TemporaryDirectory() as temp_dir: try: - logger.debug('beginning export from thread {}'.format( - threading.current_thread().ident)) + logger.debug( + "beginning export from thread {}".format(threading.current_thread().ident) + ) self._run_disk_export(temp_dir, filepaths, passphrase) self.export_usb_call_success.emit() - logger.debug('Export successful') + logger.debug("Export successful") except ExportError as e: logger.error(e) self.export_usb_call_failure.emit(e) @@ -321,19 +312,20 @@ def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None @pyqtSlot(list) def print(self, filepaths: List[str]) -> None: - ''' + """ Print the file to the printer attached to the Export VM. Args: filepath: The path of file to export. - ''' + """ with TemporaryDirectory() as temp_dir: try: - logger.debug('beginning printer from thread {}'.format( - threading.current_thread().ident)) + logger.debug( + "beginning printer from thread {}".format(threading.current_thread().ident) + ) self._run_print(temp_dir, filepaths) self.print_call_success.emit() - logger.debug('Print successful') + logger.debug("Print successful") except ExportError as e: logger.error(e) self.print_call_failure.emit(e) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 76dd00696..8c339cabc 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -18,10 +18,10 @@ """ from typing import Union -from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPushButton, QWidget from PyQt5.QtCore import QSize, Qt +from PyQt5.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget -from securedrop_client.resources import load_svg, load_icon +from securedrop_client.resources import load_icon, load_svg class SvgToggleButton(QPushButton): @@ -106,7 +106,8 @@ def __init__( disabled=disabled, active=active, selected=selected, - disabled_off=disabled) + disabled_off=disabled, + ) self.setIcon(self.icon) self.setIconSize(svg_size) if svg_size else self.setIconSize(QSize()) @@ -179,16 +180,16 @@ def get_elided_text(self, full_text: str) -> str: return full_text # Only allow one line of elided text - if '\n' in full_text: - full_text = full_text.split('\n', 1)[0] + if "\n" in full_text: + full_text = full_text.split("\n", 1)[0] fm = self.fontMetrics() filename_width = fm.horizontalAdvance(full_text) if filename_width > self.max_length: - elided_text = '' + elided_text = "" for c in full_text: if fm.horizontalAdvance(elided_text) > self.max_length: - elided_text = elided_text[:-3] + '...' + elided_text = elided_text[:-3] + "..." return elided_text elided_text = elided_text + c diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index da9461935..718e8de0e 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -20,16 +20,22 @@ along with this program. If not, see . """ import logging - from gettext import gettext as _ from typing import Dict, List, Optional # noqa: F401 -from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QHBoxLayout, \ - QVBoxLayout, QDesktopWidget + +from PyQt5.QtWidgets import ( + QApplication, + QDesktopWidget, + QHBoxLayout, + QMainWindow, + QVBoxLayout, + QWidget, +) from securedrop_client import __version__ from securedrop_client.db import Source, User +from securedrop_client.gui.widgets import LeftPane, LoginDialog, MainView, TopPane from securedrop_client.logic import Controller # noqa: F401 -from securedrop_client.gui.widgets import TopPane, LeftPane, MainView, LoginDialog from securedrop_client.resources import load_css, load_font, load_icon logger = logging.getLogger(__name__) @@ -41,7 +47,7 @@ class Window(QMainWindow): All interactions with the UI go through the object created by this class. """ - icon = 'icon.png' + icon = "icon.png" def __init__(self) -> None: """ @@ -55,9 +61,9 @@ def __init__(self) -> None: """ super().__init__() - load_font('Montserrat') - load_font('Source_Sans_Pro') - self.setStyleSheet(load_css('sdclient.css')) + load_font("Montserrat") + load_font("Source_Sans_Pro") + self.setStyleSheet(load_css("sdclient.css")) self.setWindowTitle(_("SecureDrop Client {}").format(__version__)) self.setWindowIcon(load_icon(self.icon)) @@ -114,7 +120,7 @@ def autosize_window(self): screen = QDesktopWidget().screenGeometry() self.resize(screen.width(), screen.height()) - def show_login(self, error: str = ''): + def show_login(self, error: str = ""): """ Show the login form. """ @@ -164,9 +170,9 @@ def show_last_sync(self, updated_on): Display a message indicating the time of last sync with the server. """ if updated_on: - self.update_activity_status(_('Last Refresh: {}').format(updated_on.humanize())) + self.update_activity_status(_("Last Refresh: {}").format(updated_on.humanize())) else: - self.update_activity_status(_('Last Refresh: never')) + self.update_activity_status(_("Last Refresh: never")) def set_logged_in_as(self, db_user: User): """ diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5b24135bf..ae2b94c99 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -16,31 +16,58 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -import logging -import arrow import html +import logging import sys - from gettext import gettext as _ -from typing import Dict, List, Union, Optional # noqa: F401 +from typing import Dict, List, Optional, Union # noqa: F401 from uuid import uuid4 -from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, \ - QObject -from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient, QKeySequence, \ - QCursor, QKeyEvent, QPixmap -from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \ - QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ - QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect, QPushButton, \ - QDialogButtonBox + +import arrow import sqlalchemy.orm.exc +from PyQt5.QtCore import QEvent, QObject, QSize, Qt, QTimer, pyqtBoundSignal, pyqtSignal, pyqtSlot +from PyQt5.QtGui import ( + QBrush, + QColor, + QCursor, + QFont, + QIcon, + QKeyEvent, + QKeySequence, + QLinearGradient, + QPalette, + QPixmap, +) +from PyQt5.QtWidgets import ( + QAction, + QApplication, + QDialog, + QDialogButtonBox, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMenu, + QMessageBox, + QPlainTextEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QStatusBar, + QToolButton, + QVBoxLayout, + QWidget, +) from securedrop_client import __version__ as sd_version -from securedrop_client.db import DraftReply, Source, Message, File, Reply, User -from securedrop_client.storage import source_exists -from securedrop_client.export import ExportStatus, ExportError +from securedrop_client.db import DraftReply, File, Message, Reply, Source, User +from securedrop_client.export import ExportError, ExportStatus from securedrop_client.gui import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton from securedrop_client.logic import Controller from securedrop_client.resources import load_css, load_icon, load_image, load_movie +from securedrop_client.storage import source_exists from securedrop_client.utils import humanize_filesize logger = logging.getLogger(__name__) @@ -57,16 +84,16 @@ def __init__(self): # Fill the background with a gradient self.online_palette = QPalette() gradient = QLinearGradient(0, 0, 1553, 0) - gradient.setColorAt(0, QColor('#1573d8')) - gradient.setColorAt(0.22, QColor('#0060d3')) - gradient.setColorAt(1, QColor('#002c53')) + gradient.setColorAt(0, QColor("#1573d8")) + gradient.setColorAt(0.22, QColor("#0060d3")) + gradient.setColorAt(1, QColor("#002c53")) self.online_palette.setBrush(QPalette.Background, QBrush(gradient)) self.offline_palette = QPalette() gradient = QLinearGradient(0, 0, 1553, 0) - gradient.setColorAt(0, QColor('#1e1e1e')) - gradient.setColorAt(0.22, QColor('#122d61')) - gradient.setColorAt(1, QColor('#0d4a81')) + gradient.setColorAt(0, QColor("#1e1e1e")) + gradient.setColorAt(0.22, QColor("#122d61")) + gradient.setColorAt(1, QColor("#0d4a81")) self.offline_palette.setBrush(QPalette.Background, QBrush(gradient)) self.setPalette(self.offline_palette) @@ -156,10 +183,11 @@ def __init__(self): self.logo = QWidget() self.online_palette = QPalette() # the sd logo on the background image becomes more faded in offline mode - self.online_palette.setBrush(QPalette.Background, QBrush(load_image('left_pane.svg'))) + self.online_palette.setBrush(QPalette.Background, QBrush(load_image("left_pane.svg"))) self.offline_palette = QPalette() - self.offline_palette.setBrush(QPalette.Background, - QBrush(load_image('left_pane_offline.svg'))) + self.offline_palette.setBrush( + QPalette.Background, QBrush(load_image("left_pane_offline.svg")) + ) self.logo.setPalette(self.offline_palette) self.logo.setAutoFillBackground(True) self.logo.setMaximumHeight(884) @@ -203,7 +231,7 @@ class SyncIcon(QLabel): def __init__(self): # Add svg images to button super().__init__() - self.setObjectName('SyncIcon') + self.setObjectName("SyncIcon") self.setFixedSize(QSize(24, 20)) self.sync_animation = load_movie("sync_disabled.gif") self.sync_animation.setScaledSize(QSize(24, 20)) @@ -218,12 +246,12 @@ def setup(self, controller): self.controller.sync_events.connect(self._on_sync) def _on_sync(self, data): - if data == 'syncing': + if data == "syncing": self.sync_animation = load_movie("sync_active.gif") self.sync_animation.setScaledSize(QSize(24, 20)) self.setMovie(self.sync_animation) self.sync_animation.start() - elif data == 'synced': + elif data == "synced": self.sync_animation = load_movie("sync.gif") self.sync_animation.setScaledSize(QSize(24, 20)) self.setMovie(self.sync_animation) @@ -252,7 +280,7 @@ def __init__(self): super().__init__() # Set css id - self.setObjectName('ActivityStatusBar') + self.setObjectName("ActivityStatusBar") # Remove grip image at bottom right-hand corner self.setSizeGripEnabled(False) @@ -283,17 +311,17 @@ def __init__(self): # Error vertical bar self.vertical_bar = QWidget() - self.vertical_bar.setObjectName('ErrorStatusBar_vertical_bar') # Set css id + self.vertical_bar.setObjectName("ErrorStatusBar_vertical_bar") # Set css id self.vertical_bar.setFixedWidth(10) # Error icon - self.label = SvgLabel('error_icon.svg', svg_size=QSize(20, 20)) - self.label.setObjectName('ErrorStatusBar_icon') # Set css id + self.label = SvgLabel("error_icon.svg", svg_size=QSize(20, 20)) + self.label.setObjectName("ErrorStatusBar_icon") # Set css id self.label.setFixedWidth(42) # Error status bar self.status_bar = QStatusBar() - self.status_bar.setObjectName('ErrorStatusBar_status_bar') # Set css id + self.status_bar.setObjectName("ErrorStatusBar_status_bar") # Set css id self.status_bar.setSizeGripEnabled(False) # Add widgets to layout @@ -361,11 +389,11 @@ def __init__(self): super().__init__() # Set css id - self.setObjectName('UserProfile') + self.setObjectName("UserProfile") # Set background palette = QPalette() - palette.setBrush(QPalette.Background, QBrush(QColor('#0096DC'))) + palette.setBrush(QPalette.Background, QBrush(QColor("#0096DC"))) self.setPalette(palette) self.setAutoFillBackground(True) self.setMinimumHeight(20) @@ -387,7 +415,7 @@ def __init__(self): # User icon self.user_icon = UserIconLabel() - self.user_icon.setObjectName('UserProfile_icon') # Set css id + self.user_icon.setObjectName("UserProfile_icon") # Set css id self.user_icon.setFixedSize(QSize(30, 30)) self.user_icon.setAlignment(Qt.AlignCenter) self.user_icon_font = QFont() @@ -443,10 +471,10 @@ class UserButton(SvgPushButton): """ def __init__(self): - super().__init__('dropdown_arrow.svg', svg_size=QSize(9, 6)) + super().__init__("dropdown_arrow.svg", svg_size=QSize(9, 6)) # Set css id - self.setObjectName('UserButton') + self.setObjectName("UserButton") self.setFixedHeight(30) @@ -462,12 +490,12 @@ def setup(self, controller): self.menu.setup(controller) def set_username(self, username): - formatted_name = _('{}').format(html.escape(username)) + formatted_name = _("{}").format(html.escape(username)) self.setText(formatted_name) if len(formatted_name) > 21: # The name will be truncated, so create a tooltip to display full # name if the mouse hovers over the widget. - self.setToolTip(_('{}').format(html.escape(username))) + self.setToolTip(_("{}").format(html.escape(username))) class UserMenu(QMenu): @@ -478,7 +506,7 @@ class UserMenu(QMenu): def __init__(self): super().__init__() - self.logout = QAction(_('SIGN OUT')) + self.logout = QAction(_("SIGN OUT")) self.logout.setFont(QFont("OpenSans", 10)) self.addAction(self.logout) self.logout.triggered.connect(self._on_logout_triggered) @@ -502,10 +530,10 @@ class LoginButton(QPushButton): """ def __init__(self): - super().__init__(_('SIGN IN')) + super().__init__(_("SIGN IN")) # Set css id - self.setObjectName('LoginButton') + self.setObjectName("LoginButton") self.setFixedHeight(40) @@ -535,7 +563,7 @@ def __init__(self, parent: QObject): super().__init__(parent) # Set id and styles - self.setObjectName('MainView') + self.setObjectName("MainView") # Set layout self.layout = QHBoxLayout(self) @@ -551,7 +579,7 @@ def __init__(self, parent: QObject): # Create widgets self.view_holder = QWidget() - self.view_holder.setObjectName('MainView_view_holder') + self.view_holder.setObjectName("MainView_view_holder") self.view_layout = QVBoxLayout() self.view_holder.setLayout(self.view_layout) self.view_layout.setContentsMargins(0, 0, 0, 0) @@ -610,7 +638,7 @@ def on_source_changed(self): # Try to get the SourceConversationWrapper from the persistent dict, # else we create it. try: - logger.debug('Drawing source conversation for {}'.format(source.uuid)) + logger.debug("Drawing source conversation for {}".format(source.uuid)) conversation_wrapper = self.source_conversations[source.uuid] # Redraw the conversation view such that new messages, replies, files appear. @@ -627,12 +655,12 @@ def delete_conversation(self, source_uuid: str) -> None: and remove the reference to it in self.source_conversations """ try: - logger.debug('Deleting SourceConversationWrapper for {}'.format(source_uuid)) + logger.debug("Deleting SourceConversationWrapper for {}".format(source_uuid)) conversation_wrapper = self.source_conversations[source_uuid] conversation_wrapper.deleteLater() del self.source_conversations[source_uuid] except KeyError: - logger.debug('No SourceConversationWrapper for {} to delete'.format(source_uuid)) + logger.debug("No SourceConversationWrapper for {} to delete".format(source_uuid)) def set_conversation(self, widget): """ @@ -656,7 +684,7 @@ class EmptyConversationView(QWidget): def __init__(self): super().__init__() - self.setObjectName('EmptyConversationView') + self.setObjectName("EmptyConversationView") # Set layout layout = QHBoxLayout() @@ -665,17 +693,19 @@ def __init__(self): # Create widgets self.no_sources = QWidget() - self.no_sources.setObjectName('EmptyConversationView_no_sources') + self.no_sources.setObjectName("EmptyConversationView_no_sources") no_sources_layout = QVBoxLayout() self.no_sources.setLayout(no_sources_layout) - no_sources_instructions = QLabel(_('Nothing to see just yet!')) - no_sources_instructions.setObjectName('EmptyConversationView_instructions') + no_sources_instructions = QLabel(_("Nothing to see just yet!")) + no_sources_instructions.setObjectName("EmptyConversationView_instructions") no_sources_instructions.setWordWrap(True) no_sources_instruction_details1 = QLabel( - _('Source submissions will be listed to the left, once downloaded and decrypted.')) + _("Source submissions will be listed to the left, once downloaded and decrypted.") + ) no_sources_instruction_details1.setWordWrap(True) no_sources_instruction_details2 = QLabel( - _('This is where you will read messages, reply to sources, and work with files.')) + _("This is where you will read messages, reply to sources, and work with files.") + ) no_sources_instruction_details2.setWordWrap(True) no_sources_layout.addWidget(no_sources_instructions) no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) @@ -684,38 +714,38 @@ def __init__(self): no_sources_layout.addWidget(no_sources_instruction_details2) self.no_source_selected = QWidget() - self.no_source_selected.setObjectName('EmptyConversationView_no_source_selected') + self.no_source_selected.setObjectName("EmptyConversationView_no_source_selected") no_source_selected_layout = QVBoxLayout() self.no_source_selected.setLayout(no_source_selected_layout) - no_source_selected_instructions = QLabel(_('Select a source from the list, to:')) - no_source_selected_instructions.setObjectName('EmptyConversationView_instructions') + no_source_selected_instructions = QLabel(_("Select a source from the list, to:")) + no_source_selected_instructions.setObjectName("EmptyConversationView_instructions") no_source_selected_instructions.setWordWrap(True) bullet1 = QWidget() bullet1_layout = QHBoxLayout() bullet1_layout.setContentsMargins(0, 0, 0, 0) bullet1.setLayout(bullet1_layout) - bullet1_bullet = QLabel('·') - bullet1_bullet.setObjectName('EmptyConversationView_bullet') + bullet1_bullet = QLabel("·") + bullet1_bullet.setObjectName("EmptyConversationView_bullet") bullet1_layout.addWidget(bullet1_bullet) - bullet1_layout.addWidget(QLabel(_('Read a conversation'))) + bullet1_layout.addWidget(QLabel(_("Read a conversation"))) bullet1_layout.addStretch() bullet2 = QWidget() bullet2_layout = QHBoxLayout() bullet2_layout.setContentsMargins(0, 0, 0, 0) bullet2.setLayout(bullet2_layout) - bullet2_bullet = QLabel('·') - bullet2_bullet.setObjectName('EmptyConversationView_bullet') + bullet2_bullet = QLabel("·") + bullet2_bullet.setObjectName("EmptyConversationView_bullet") bullet2_layout.addWidget(bullet2_bullet) - bullet2_layout.addWidget(QLabel(_('View or retrieve files'))) + bullet2_layout.addWidget(QLabel(_("View or retrieve files"))) bullet2_layout.addStretch() bullet3 = QWidget() bullet3_layout = QHBoxLayout() bullet3_layout.setContentsMargins(0, 0, 0, 0) bullet3.setLayout(bullet3_layout) - bullet3_bullet = QLabel('·') - bullet3_bullet.setObjectName('EmptyConversationView_bullet') + bullet3_bullet = QLabel("·") + bullet3_bullet.setObjectName("EmptyConversationView_bullet") bullet3_layout.addWidget(bullet3_bullet) - bullet3_layout.addWidget(QLabel(_('Send a response'))) + bullet3_layout.addWidget(QLabel(_("Send a response"))) bullet3_layout.addStretch() no_source_selected_layout.addWidget(no_source_selected_instructions) no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX) @@ -738,7 +768,6 @@ def show_no_source_selected_message(self): class SourceListWidgetItem(QListWidgetItem): - def __lt__(self, other): """ Used for ordering widgets by timestamp of last interaction. @@ -763,7 +792,7 @@ class SourceList(QListWidget): def __init__(self): super().__init__() - self.setObjectName('SourceList') + self.setObjectName("SourceList") self.setFixedWidth(540) self.setUniformItemSizes(True) @@ -805,8 +834,9 @@ def update(self, sources: List[Source]) -> List[str]: # Delete widgets for sources not in the supplied sourcelist deleted_uuids = [] - sources_to_delete = [self.source_items[uuid] for uuid in self.source_items - if uuid not in sources_to_update] + sources_to_delete = [ + self.source_items[uuid] for uuid in self.source_items if uuid not in sources_to_update + ] for source_item in sources_to_delete: if source_item.isSelected(): self.setCurrentItem(None) @@ -898,9 +928,9 @@ def get_selected_source(self): return source_widget.source def get_source_widget(self, source_uuid: str) -> Optional[QListWidget]: - ''' + """ First try to get the source widget from the cache, then look for it in the SourceList. - ''' + """ try: source_item = self.source_items[source_uuid] return self.itemWidget(source_item) @@ -917,12 +947,12 @@ def get_source_widget(self, source_uuid: str) -> Optional[QListWidget]: @pyqtSlot(str, str, str) def set_snippet(self, source_uuid: str, collection_item_uuid: str, content: str) -> None: - ''' + """ Set the source widget's preview snippet with the supplied content. Note: The signal's `collection_item_uuid` is not needed for setting the preview snippet. It is used by other signal handlers. - ''' + """ source_widget = self.get_source_widget(source_uuid) if source_widget: source_widget.set_snippet(source_uuid, content) @@ -987,7 +1017,7 @@ def __init__(self, controller: Controller, source: Source): # Set up gutter self.gutter = QWidget() - self.gutter.setObjectName('SourceWidget_gutter') + self.gutter.setObjectName("SourceWidget_gutter") self.gutter.setSizePolicy(retain_space) gutter_layout = QVBoxLayout(self.gutter) gutter_layout.setContentsMargins(0, 0, 0, 0) @@ -998,19 +1028,20 @@ def __init__(self, controller: Controller, source: Source): # Set up summary self.summary = QWidget() - self.summary.setObjectName('SourceWidget_summary') + self.summary.setObjectName("SourceWidget_summary") summary_layout = QVBoxLayout(self.summary) summary_layout.setContentsMargins(0, 0, 0, 0) summary_layout.setSpacing(0) self.name = QLabel() - self.name.setObjectName('SourceWidget_name') + self.name.setObjectName("SourceWidget_name") self.preview = SecureQLabel(max_length=self.PREVIEW_WIDTH) - self.preview.setObjectName('SourceWidget_preview') + self.preview.setObjectName("SourceWidget_preview") self.preview.setFixedSize(QSize(self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT)) - self.waiting_delete_confirmation = QLabel('Deletion in progress') - self.waiting_delete_confirmation.setObjectName('SourceWidget_source_deleted') + self.waiting_delete_confirmation = QLabel("Deletion in progress") + self.waiting_delete_confirmation.setObjectName("SourceWidget_source_deleted") self.waiting_delete_confirmation.setFixedSize( - QSize(self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT)) + QSize(self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT) + ) self.waiting_delete_confirmation.hide() summary_layout.addWidget(self.name) summary_layout.addWidget(self.preview) @@ -1018,26 +1049,27 @@ def __init__(self, controller: Controller, source: Source): # Set up metadata self.metadata = QWidget() - self.metadata.setObjectName('SourceWidget_metadata') + self.metadata.setObjectName("SourceWidget_metadata") self.metadata.setSizePolicy(retain_space) metadata_layout = QVBoxLayout(self.metadata) metadata_layout.setContentsMargins(0, 0, 0, 0) metadata_layout.setSpacing(0) - self.paperclip = SvgLabel('paperclip.svg', QSize(18, 18)) # Set to size provided in the svg - self.paperclip.setObjectName('SourceWidget_paperclip') + self.paperclip = SvgLabel("paperclip.svg", QSize(18, 18)) # Set to size provided in the svg + self.paperclip.setObjectName("SourceWidget_paperclip") self.paperclip.setFixedSize(QSize(22, 22)) self.timestamp = QLabel() - self.timestamp.setObjectName('SourceWidget_timestamp') + self.timestamp.setObjectName("SourceWidget_timestamp") metadata_layout.addWidget(self.paperclip, 0, Qt.AlignRight) metadata_layout.addWidget(self.timestamp, 0, Qt.AlignRight) metadata_layout.addStretch() # Set up a source_widget self.source_widget = QWidget() - self.source_widget.setObjectName('SourceWidget_container') + self.source_widget.setObjectName("SourceWidget_container") source_widget_layout = QHBoxLayout(self.source_widget) source_widget_layout.setContentsMargins( - 0, self.SOURCE_WIDGET_VERTICAL_MARGIN, 0, self.SOURCE_WIDGET_VERTICAL_MARGIN) + 0, self.SOURCE_WIDGET_VERTICAL_MARGIN, 0, self.SOURCE_WIDGET_VERTICAL_MARGIN + ) source_widget_layout.setSpacing(0) source_widget_layout.addWidget(self.gutter) source_widget_layout.addWidget(self.summary) @@ -1055,11 +1087,11 @@ def update(self): try: self.controller.session.refresh(self.source) self.last_updated = self.source.last_updated - self.timestamp.setText(_(arrow.get(self.source.last_updated).format('DD MMM'))) + self.timestamp.setText(_(arrow.get(self.source.last_updated).format("DD MMM"))) self.name.setText(self.source.journalist_designation) if not self.source.server_collection: - self.set_snippet(self.source_uuid, '') + self.set_snippet(self.source_uuid, "") else: last_collection_obj = self.source.server_collection[-1] self.set_snippet(self.source_uuid, str(last_collection_obj)) @@ -1110,7 +1142,7 @@ class StarToggleButton(SvgToggleButton): """ def __init__(self, controller: Controller, source_uuid: str, is_starred: bool): - super().__init__(on='star_on.svg', off='star_off.svg', svg_size=QSize(16, 16)) + super().__init__(on="star_on.svg", off="star_off.svg", svg_size=QSize(16, 16)) self.controller = controller self.source_uuid = source_uuid @@ -1123,7 +1155,7 @@ def __init__(self, controller: Controller, source_uuid: str, is_starred: bool): self.controller.star_update_successful.connect(self.on_star_update_successful) self.installEventFilter(self) - self.setObjectName('StarToggleButton') + self.setObjectName("StarToggleButton") self.setFixedSize(QSize(20, 20)) self.pressed.connect(self.on_pressed) @@ -1146,7 +1178,7 @@ def disable_toggle(self): # the source as starred. We could instead disable the button, which will continue to show # the star as checked, but Qt will also gray out the star, which we don't want. if self.is_starred: - self.set_icon(on='star_on.svg', off='star_on.svg') + self.set_icon(on="star_on.svg", off="star_on.svg") self.setCheckable(False) def enable_toggle(self): @@ -1161,7 +1193,7 @@ def enable_toggle(self): self.pressed.disconnect() self.pressed.connect(self.on_pressed) self.setCheckable(True) - self.set_icon(on='star_on.svg', off='star_off.svg') # Undo icon change from disable_toggle + self.set_icon(on="star_on.svg", off="star_off.svg") # Undo icon change from disable_toggle def eventFilter(self, obj, event): """ @@ -1172,9 +1204,9 @@ def eventFilter(self, obj, event): t = event.type() if t == QEvent.HoverEnter: - self.setIcon(load_icon('star_hover.svg')) + self.setIcon(load_icon("star_hover.svg")) elif t == QEvent.HoverLeave or t == QEvent.MouseButtonPress: - self.set_icon(on='star_on.svg', off='star_off.svg') + self.set_icon(on="star_on.svg", off="star_off.svg") return QObject.event(obj, event) @@ -1268,10 +1300,11 @@ def launch(self): """ message = self._construct_message(self.source) reply = QMessageBox.question( - None, "", _(message), QMessageBox.Cancel | QMessageBox.Yes, QMessageBox.Cancel) + None, "", _(message), QMessageBox.Cancel | QMessageBox.Yes, QMessageBox.Cancel + ) if reply == QMessageBox.Yes: - logger.debug(f'Deleting source {self.source_uuid}') + logger.debug(f"Deleting source {self.source_uuid}") self.controller.delete_source(self.source) def _construct_message(self, source: Source) -> str: @@ -1294,7 +1327,7 @@ def _construct_message(self, source: Source) -> str: "This Source will no longer be able to correspond", "through the log-in tied to this account.", ) - message = ' '.join(message_tuple) + message = " ".join(message_tuple) return message @@ -1310,11 +1343,11 @@ def __init__(self): super().__init__() # Set css id - self.setObjectName('LoginOfflineLink') + self.setObjectName("LoginOfflineLink") self.setFixedSize(QSize(120, 22)) - self.setText(_('USE OFFLINE')) + self.setText(_("USE OFFLINE")) def mouseReleaseEvent(self, event): self.clicked.emit() @@ -1326,10 +1359,10 @@ class SignInButton(QPushButton): """ def __init__(self): - super().__init__(_('SIGN IN')) + super().__init__(_("SIGN IN")) # Set css id - self.setObjectName('SignInButton') + self.setObjectName("SignInButton") self.setFixedHeight(40) self.setFixedWidth(140) @@ -1341,7 +1374,7 @@ def __init__(self): effect = QGraphicsDropShadowEffect(self) effect.setOffset(0, 1) effect.setBlurRadius(8) - effect.setColor(QColor('#aa000000')) + effect.setColor(QColor("#aa000000")) self.setGraphicsEffect(effect) self.update() @@ -1354,7 +1387,7 @@ class LoginErrorBar(QWidget): def __init__(self): super().__init__() - self.setObjectName('LoginErrorBar') + self.setObjectName("LoginErrorBar") # Set layout layout = QHBoxLayout(self) @@ -1370,13 +1403,13 @@ def __init__(self): self.setSizePolicy(retain_space) # Error icon - self.error_icon = SvgLabel('error_icon_white.svg', svg_size=QSize(18, 18)) - self.error_icon.setObjectName('LoginErrorBar_icon') + self.error_icon = SvgLabel("error_icon_white.svg", svg_size=QSize(18, 18)) + self.error_icon.setObjectName("LoginErrorBar_icon") self.error_icon.setFixedWidth(42) # Error status bar self.error_status_bar = SecureQLabel(wordwrap=False) - self.error_status_bar.setObjectName('LoginErrorBar_status_bar') + self.error_status_bar.setObjectName("LoginErrorBar_status_bar") self.setFixedHeight(42) # Create space ths size of the error icon to keep the error message centered @@ -1394,7 +1427,7 @@ def set_message(self, message): self.error_status_bar.setText(message) def clear_message(self): - self.error_status_bar.setText('') + self.error_status_bar.setText("") self.hide() @@ -1453,7 +1486,7 @@ def __init__(self, parent): # Set background self.setAutoFillBackground(True) palette = QPalette() - palette.setBrush(QPalette.Background, QBrush(load_image('login_bg.svg'))) + palette.setBrush(QPalette.Background, QBrush(load_image("login_bg.svg"))) self.setPalette(palette) self.setFixedSize(QSize(596, 671)) # Set to size provided in the login_bg.svg file self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) @@ -1464,7 +1497,7 @@ def __init__(self, parent): # Create form widget form = QWidget() - form.setObjectName('LoginDialog_form') + form.setObjectName("LoginDialog_form") form_layout = QVBoxLayout() form.setLayout(form_layout) @@ -1472,13 +1505,13 @@ def __init__(self, parent): form_layout.setContentsMargins(80, 0, 80, 0) form_layout.setSpacing(8) - self.username_label = QLabel(_('Username')) + self.username_label = QLabel(_("Username")) self.username_field = QLineEdit() - self.password_label = QLabel(_('Passphrase')) + self.password_label = QLabel(_("Passphrase")) self.password_field = PasswordEdit(self) - self.tfa_label = QLabel(_('Two-Factor Code')) + self.tfa_label = QLabel(_("Two-Factor Code")) self.tfa_field = QLineEdit() buttons = QWidget() @@ -1508,7 +1541,7 @@ def __init__(self, parent): application_version.setLayout(application_version_layout) application_version_label = QLabel(_("SecureDrop Client v") + sd_version) application_version_label.setAlignment(Qt.AlignHCenter) - application_version_label.setObjectName('LoginDialog_app_version_label') + application_version_label.setObjectName("LoginDialog_app_version_label") application_version_layout.addWidget(application_version_label) # Add widgets @@ -1546,10 +1579,10 @@ def reset(self): """ Resets the login form to the default state. """ - self.username_field.setText('') + self.username_field.setText("") self.username_field.setFocus() - self.password_field.setText('') - self.tfa_field.setText('') + self.password_field.setText("") + self.tfa_field.setText("") self.setDisabled(False) self.error_bar.clear_message() @@ -1572,20 +1605,25 @@ def validate(self): self.setDisabled(True) username = self.username_field.text() password = self.password_field.text() - tfa_token = self.tfa_field.text().replace(' ', '') + tfa_token = self.tfa_field.text().replace(" ", "") if username and password and tfa_token: # Validate username if len(username) < self.MIN_JOURNALIST_USERNAME: self.setDisabled(False) - self.error(_('That username won\'t work.\n' - 'It should be at least 3 characters long.')) + self.error( + _("That username won't work.\n" "It should be at least 3 characters long.") + ) return # Validate password if len(password) < self.MIN_PASSWORD_LEN or len(password) > self.MAX_PASSWORD_LEN: self.setDisabled(False) - self.error(_('That passphrase won\'t work.\n' - 'It should be between 14 and 128 characters long.')) + self.error( + _( + "That passphrase won't work.\n" + "It should be between 14 and 128 characters long." + ) + ) return # Validate 2FA token @@ -1593,15 +1631,15 @@ def validate(self): int(tfa_token) except ValueError: self.setDisabled(False) - self.error(_('That two-factor code won\'t work.\n' - 'It should only contain numerals.')) + self.error( + _("That two-factor code won't work.\n" "It should only contain numerals.") + ) return self.submit.setText(_("SIGNING IN")) self.controller.login(username, password, tfa_token) else: self.setDisabled(False) - self.error(_('Please enter a username, passphrase and ' - 'two-factor code.')) + self.error(_("Please enter a username, passphrase and " "two-factor code.")) class SpeechBubble(QWidget): @@ -1610,14 +1648,21 @@ class SpeechBubble(QWidget): and journalist. """ - MESSAGE_CSS = load_css('speech_bubble_message.css') - STATUS_BAR_CSS = load_css('speech_bubble_status_bar.css') + MESSAGE_CSS = load_css("speech_bubble_message.css") + STATUS_BAR_CSS = load_css("speech_bubble_status_bar.css") TOP_MARGIN = 28 BOTTOM_MARGIN = 10 - def __init__(self, message_uuid: str, text: str, update_signal, - download_error_signal, index: int, error: bool = False) -> None: + def __init__( + self, + message_uuid: str, + text: str, + update_signal, + download_error_signal, + index: int, + error: bool = False, + ) -> None: super().__init__() self.uuid = message_uuid self.index = index @@ -1632,17 +1677,17 @@ def __init__(self, message_uuid: str, text: str, update_signal, # Message box self.message = SecureQLabel(text) - self.message.setObjectName('SpeechBubble_message') + self.message.setObjectName("SpeechBubble_message") self.message.setStyleSheet(self.MESSAGE_CSS) # Color bar self.color_bar = QWidget() - self.color_bar.setObjectName('SpeechBubble_status_bar') + self.color_bar.setObjectName("SpeechBubble_status_bar") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) # Speech bubble self.speech_bubble = QWidget() - self.speech_bubble.setObjectName('SpeechBubble_container') + self.speech_bubble.setObjectName("SpeechBubble_container") speech_bubble_layout = QVBoxLayout() self.speech_bubble.setLayout(speech_bubble_layout) speech_bubble_layout.addWidget(self.message) @@ -1694,19 +1739,19 @@ def set_error(self, source_uuid: str, uuid: str, text: str): self.set_error_styles() def set_normal_styles(self): - self.message.setStyleSheet('') - self.message.setObjectName('SpeechBubble_message') + self.message.setStyleSheet("") + self.message.setObjectName("SpeechBubble_message") self.message.setStyleSheet(self.MESSAGE_CSS) - self.color_bar.setStyleSheet('') - self.color_bar.setObjectName('SpeechBubble_status_bar') + self.color_bar.setStyleSheet("") + self.color_bar.setObjectName("SpeechBubble_status_bar") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) def set_error_styles(self): - self.message.setStyleSheet('') - self.message.setObjectName('SpeechBubble_message_decryption_error') + self.message.setStyleSheet("") + self.message.setObjectName("SpeechBubble_message_decryption_error") self.message.setStyleSheet(self.MESSAGE_CSS) - self.color_bar.setStyleSheet('') - self.color_bar.setObjectName('SpeechBubble_status_bar_decryption_error') + self.color_bar.setStyleSheet("") + self.color_bar.setObjectName("SpeechBubble_status_bar_decryption_error") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) @@ -1715,8 +1760,15 @@ class MessageWidget(SpeechBubble): Represents an incoming message from the source. """ - def __init__(self, message_uuid: str, message: str, update_signal, - download_error_signal, index: int, error: bool = False) -> None: + def __init__( + self, + message_uuid: str, + message: str, + update_signal, + download_error_signal, + index: int, + error: bool = False, + ) -> None: super().__init__(message_uuid, message, update_signal, download_error_signal, index, error) @@ -1725,8 +1777,8 @@ class ReplyWidget(SpeechBubble): Represents a reply to a source. """ - MESSAGE_CSS = load_css('reply_message.css') - STATUS_BAR_CSS = load_css('reply_status_bar.css') + MESSAGE_CSS = load_css("reply_message.css") + STATUS_BAR_CSS = load_css("reply_status_bar.css") def __init__( self, @@ -1748,9 +1800,9 @@ def __init__( error_layout.setContentsMargins(0, 0, 0, 0) error_layout.setSpacing(4) self.error.setLayout(error_layout) - error_message = SecureQLabel('Failed to send', wordwrap=False) - error_message.setObjectName('ReplyWidget_failed_to_send_text') - error_icon = SvgLabel('error_icon.svg', svg_size=QSize(12, 12)) + error_message = SecureQLabel("Failed to send", wordwrap=False) + error_message.setObjectName("ReplyWidget_failed_to_send_text") + error_icon = SvgLabel("error_icon.svg", svg_size=QSize(12, 12)) error_icon.setFixedWidth(12) error_layout.addWidget(error_message) error_layout.addWidget(error_icon) @@ -1764,15 +1816,15 @@ def __init__( self._set_reply_state(reply_status) def _set_reply_state(self, status: str) -> None: - logger.debug(f'Setting ReplyWidget state: {status}') + logger.debug(f"Setting ReplyWidget state: {status}") - if status == 'SUCCEEDED': + if status == "SUCCEEDED": self.set_normal_styles() self.error.hide() - elif status == 'FAILED': + elif status == "FAILED": self.set_failed_styles() self.error.show() - elif status == 'PENDING': + elif status == "PENDING": self.set_pending_styles() @pyqtSlot(str, str, str) @@ -1782,7 +1834,7 @@ def _on_reply_success(self, source_id: str, message_uuid: str, content: str) -> signal matches the uuid of this widget. """ if message_uuid == self.uuid: - self._set_reply_state('SUCCEEDED') + self._set_reply_state("SUCCEEDED") @pyqtSlot(str) def _on_reply_failure(self, message_uuid: str) -> None: @@ -1791,30 +1843,30 @@ def _on_reply_failure(self, message_uuid: str) -> None: signal matches the uuid of this widget. """ if message_uuid == self.uuid: - self._set_reply_state('FAILED') + self._set_reply_state("FAILED") def set_normal_styles(self): - self.message.setStyleSheet('') - self.message.setObjectName('ReplyWidget_message') + self.message.setStyleSheet("") + self.message.setObjectName("ReplyWidget_message") self.message.setStyleSheet(self.MESSAGE_CSS) - self.color_bar.setStyleSheet('') - self.color_bar.setObjectName('ReplyWidget_status_bar') + self.color_bar.setStyleSheet("") + self.color_bar.setObjectName("ReplyWidget_status_bar") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) def set_failed_styles(self): - self.message.setStyleSheet('') - self.message.setObjectName('ReplyWidget_message_failed') + self.message.setStyleSheet("") + self.message.setObjectName("ReplyWidget_message_failed") self.message.setStyleSheet(self.MESSAGE_CSS) - self.color_bar.setStyleSheet('') - self.color_bar.setObjectName('ReplyWidget_status_bar_failed') + self.color_bar.setStyleSheet("") + self.color_bar.setObjectName("ReplyWidget_status_bar_failed") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) def set_pending_styles(self): - self.message.setStyleSheet('') - self.message.setObjectName('ReplyWidget_message_pending') + self.message.setStyleSheet("") + self.message.setObjectName("ReplyWidget_message_pending") self.message.setStyleSheet(self.MESSAGE_CSS) - self.color_bar.setStyleSheet('') - self.color_bar.setObjectName('ReplyWidget_status_bar_pending') + self.color_bar.setStyleSheet("") + self.color_bar.setObjectName("ReplyWidget_status_bar_pending") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) @@ -1823,7 +1875,7 @@ class FileWidget(QWidget): Represents a file. """ - DOWNLOAD_BUTTON_CSS = load_css('file_download_button.css') + DOWNLOAD_BUTTON_CSS = load_css("file_download_button.css") TOP_MARGIN = 4 BOTTOM_MARGIN = 14 @@ -1851,12 +1903,13 @@ def __init__( self.index = index self.downloading = False - self.setObjectName('FileWidget') + self.setObjectName("FileWidget") file_description_font = QFont() file_description_font.setLetterSpacing(QFont.AbsoluteSpacing, self.FILE_FONT_SPACING) self.file_buttons_font = QFont() self.file_buttons_font.setLetterSpacing( - QFont.AbsoluteSpacing, self.FILE_OPTIONS_FONT_SPACING) + QFont.AbsoluteSpacing, self.FILE_OPTIONS_FONT_SPACING + ) # Set layout layout = QHBoxLayout() @@ -1868,27 +1921,27 @@ def __init__( # File options: download, export, print self.file_options = QWidget() - self.file_options.setObjectName('FileWidget_file_options') + self.file_options.setObjectName("FileWidget_file_options") file_options_layout = QHBoxLayout() self.file_options.setLayout(file_options_layout) file_options_layout.setContentsMargins(0, 0, 0, 0) file_options_layout.setSpacing(self.FILE_OPTIONS_LAYOUT_SPACING) file_options_layout.setAlignment(Qt.AlignLeft) - self.download_button = QPushButton(_(' DOWNLOAD')) - self.download_button.setObjectName('FileWidget_download_button') + self.download_button = QPushButton(_(" DOWNLOAD")) + self.download_button.setObjectName("FileWidget_download_button") self.download_button.setStyleSheet(self.DOWNLOAD_BUTTON_CSS) self.download_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - self.download_button.setIcon(load_icon('download_file.svg')) + self.download_button.setIcon(load_icon("download_file.svg")) self.download_button.setFont(self.file_buttons_font) self.download_button.setCursor(QCursor(Qt.PointingHandCursor)) self.download_animation = load_movie("download_file.gif") - self.export_button = QPushButton(_('EXPORT')) - self.export_button.setObjectName('FileWidget_export_print') + self.export_button = QPushButton(_("EXPORT")) + self.export_button.setObjectName("FileWidget_export_print") self.export_button.setFont(self.file_buttons_font) self.export_button.setCursor(QCursor(Qt.PointingHandCursor)) self.middot = QLabel("·") - self.print_button = QPushButton(_('PRINT')) - self.print_button.setObjectName('FileWidget_export_print') + self.print_button = QPushButton(_("PRINT")) + self.print_button.setObjectName("FileWidget_export_print") self.print_button.setFont(self.file_buttons_font) self.print_button.setCursor(QCursor(Qt.PointingHandCursor)) file_options_layout.addWidget(self.download_button) @@ -1903,16 +1956,16 @@ def __init__( self.file_name = SecureQLabel( wordwrap=False, max_length=self.FILENAME_WIDTH_PX, with_tooltip=True ) - self.file_name.setObjectName('FileWidget_file_name') + self.file_name.setObjectName("FileWidget_file_name") self.file_name.installEventFilter(self) self.file_name.setCursor(QCursor(Qt.PointingHandCursor)) - self.no_file_name = SecureQLabel('ENCRYPTED FILE ON SERVER', wordwrap=False) - self.no_file_name.setObjectName('FileWidget_no_file_name') + self.no_file_name = SecureQLabel("ENCRYPTED FILE ON SERVER", wordwrap=False) + self.no_file_name.setObjectName("FileWidget_no_file_name") self.no_file_name.setFont(file_description_font) # Line between file name and file size self.horizontal_line = QWidget() - self.horizontal_line.setObjectName('FileWidget_horizontal_line') + self.horizontal_line.setObjectName("FileWidget_horizontal_line") self.horizontal_line.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Space between elided file name and file size when horizontal line is hidden @@ -1922,7 +1975,7 @@ def __init__( # File size self.file_size = SecureQLabel(humanize_filesize(self.file.size)) - self.file_size.setObjectName('FileWidget_file_size') + self.file_size.setObjectName("FileWidget_file_size") self.file_size.setAlignment(Qt.AlignRight) # Decide what to show or hide based on whether or not the file's been downloaded @@ -1955,14 +2008,14 @@ def eventFilter(self, obj, event): # See https://github.com/freedomofpress/securedrop-client/issues/835 # for context on code below. if t == QEvent.HoverEnter and not self.downloading: - self.download_button.setIcon(load_icon('download_file_hover.svg')) + self.download_button.setIcon(load_icon("download_file_hover.svg")) elif t == QEvent.HoverLeave and not self.downloading: - self.download_button.setIcon(load_icon('download_file.svg')) + self.download_button.setIcon(load_icon("download_file.svg")) return QObject.event(obj, event) def _set_file_state(self): if self.file.is_decrypted: - logger.debug('Changing file {} state to decrypted/downloaded'.format(self.uuid)) + logger.debug("Changing file {} state to decrypted/downloaded".format(self.uuid)) self._set_file_name() self.download_button.hide() self.no_file_name.hide() @@ -1972,21 +2025,21 @@ def _set_file_state(self): self.file_name.show() self.update_file_size() else: - logger.debug('Changing file {} state to not downloaded'.format(self.uuid)) - self.download_button.setText(_('DOWNLOAD')) + logger.debug("Changing file {} state to not downloaded".format(self.uuid)) + self.download_button.setText(_("DOWNLOAD")) # Ensure correct icon depending on mouse hover state. if self.download_button.underMouse(): - self.download_button.setIcon(load_icon('download_file_hover.svg')) + self.download_button.setIcon(load_icon("download_file_hover.svg")) else: - self.download_button.setIcon(load_icon('download_file.svg')) + self.download_button.setIcon(load_icon("download_file.svg")) self.download_button.setFont(self.file_buttons_font) self.download_button.show() # Reset stylesheet - self.download_button.setStyleSheet('') - self.download_button.setObjectName('FileWidget_download_button') + self.download_button.setStyleSheet("") + self.download_button.setObjectName("FileWidget_download_button") self.download_button.setStyleSheet(self.DOWNLOAD_BUTTON_CSS) self.no_file_name.hide() @@ -2063,8 +2116,8 @@ def start_button_animation(self): self.download_button.setText(_(" DOWNLOADING ")) # Reset widget stylesheet - self.download_button.setStyleSheet('') - self.download_button.setObjectName('FileWidget_download_button_animating') + self.download_button.setStyleSheet("") + self.download_button.setObjectName("FileWidget_download_button_animating") self.download_button.setStyleSheet(self.DOWNLOAD_BUTTON_CSS) def set_button_animation_frame(self, frame_number): @@ -2085,8 +2138,8 @@ def stop_button_animation(self): class ModalDialog(QDialog): - CONTINUE_BUTTON_CSS = load_css('modal_dialog_button.css') - ERROR_DETAILS_CSS = load_css('modal_dialog_error_details.css') + CONTINUE_BUTTON_CSS = load_css("modal_dialog_button.css") + ERROR_DETAILS_CSS = load_css("modal_dialog_error_details.css") MARGIN = 40 NO_MARGIN = 0 @@ -2094,15 +2147,15 @@ class ModalDialog(QDialog): def __init__(self): parent = QApplication.activeWindow() super().__init__(parent) - self.setObjectName('ModalDialog') + self.setObjectName("ModalDialog") self.setModal(True) # Header for icon and task title header_container = QWidget() header_container_layout = QHBoxLayout() header_container.setLayout(header_container_layout) - self.header_icon = SvgLabel('blank.svg', svg_size=QSize(64, 64)) - self.header_icon.setObjectName('ModalDialog_header_icon') + self.header_icon = SvgLabel("blank.svg", svg_size=QSize(64, 64)) + self.header_icon.setObjectName("ModalDialog_header_icon") self.header_spinner = QPixmap() self.header_spinner_label = QLabel() self.header_spinner_label.setObjectName("ModalDialog_header_spinner") @@ -2110,25 +2163,25 @@ def __init__(self): self.header_spinner_label.setVisible(False) self.header_spinner_label.setPixmap(self.header_spinner) self.header = QLabel() - self.header.setObjectName('ModalDialog_header') + self.header.setObjectName("ModalDialog_header") header_container_layout.addWidget(self.header_icon) header_container_layout.addWidget(self.header_spinner_label) header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) header_container_layout.addStretch() self.header_line = QWidget() - self.header_line.setObjectName('ModalDialog_header_line') + self.header_line.setObjectName("ModalDialog_header_line") # Widget for displaying error messages self.error_details = QLabel() - self.error_details.setObjectName('ModalDialog_error_details') + self.error_details.setObjectName("ModalDialog_error_details") self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) self.error_details.setWordWrap(True) self.error_details.hide() # Body to display instructions and forms self.body = QLabel() - self.body.setObjectName('ModalDialog_body') + self.body.setObjectName("ModalDialog_body") self.body.setWordWrap(True) self.body.setScaledContents(True) body_container = QWidget() @@ -2139,19 +2192,19 @@ def __init__(self): # Buttons to continue and cancel window_buttons = QWidget() - window_buttons.setObjectName('ModalDialog_window_buttons') + window_buttons.setObjectName("ModalDialog_window_buttons") button_layout = QVBoxLayout() window_buttons.setLayout(button_layout) - self.cancel_button = QPushButton(_('CANCEL')) + self.cancel_button = QPushButton(_("CANCEL")) self.cancel_button.clicked.connect(self.close) self.cancel_button.setAutoDefault(False) - self.continue_button = QPushButton(_('CONTINUE')) - self.continue_button.setObjectName('ModalDialog_primary_button') + self.continue_button = QPushButton(_("CONTINUE")) + self.continue_button.setObjectName("ModalDialog_primary_button") self.continue_button.setStyleSheet(self.CONTINUE_BUTTON_CSS) self.continue_button.setDefault(True) self.continue_button.setIconSize(QSize(21, 21)) button_box = QDialogButtonBox(Qt.Horizontal) - button_box.setObjectName('ModalDialog_button_box') + button_box.setObjectName("ModalDialog_button_box") button_box.addButton(self.cancel_button, QDialogButtonBox.ActionRole) button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) button_layout.addWidget(button_box, alignment=Qt.AlignRight) @@ -2178,7 +2231,7 @@ def __init__(self): self.header_animation.frameChanged.connect(self.animate_header) def keyPressEvent(self, event: QKeyEvent): - if (event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return): + if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: if self.cancel_button.hasFocus(): self.cancel_button.click() else: @@ -2198,11 +2251,11 @@ def start_animate_activestate(self): self.continue_button.setText("") self.continue_button.setMinimumSize(QSize(142, 43)) # Reset widget stylesheets - self.continue_button.setStyleSheet('') - self.continue_button.setObjectName('ModalDialog_primary_button_active') + self.continue_button.setStyleSheet("") + self.continue_button.setObjectName("ModalDialog_primary_button_active") self.continue_button.setStyleSheet(self.CONTINUE_BUTTON_CSS) - self.error_details.setStyleSheet('') - self.error_details.setObjectName('ModalDialog_error_details_active') + self.error_details.setStyleSheet("") + self.error_details.setObjectName("ModalDialog_error_details_active") self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) def start_animate_header(self): @@ -2213,13 +2266,13 @@ def start_animate_header(self): def stop_animate_activestate(self): self.continue_button.setIcon(QIcon()) self.button_animation.stop() - self.continue_button.setText(_('CONTINUE')) + self.continue_button.setText(_("CONTINUE")) # Reset widget stylesheets - self.continue_button.setStyleSheet('') - self.continue_button.setObjectName('ModalDialog_primary_button') + self.continue_button.setStyleSheet("") + self.continue_button.setObjectName("ModalDialog_primary_button") self.continue_button.setStyleSheet(self.CONTINUE_BUTTON_CSS) - self.error_details.setStyleSheet('') - self.error_details.setObjectName('ModalDialog_error_details') + self.error_details.setStyleSheet("") + self.error_details.setObjectName("ModalDialog_error_details") self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) def stop_animate_header(self): @@ -2238,8 +2291,9 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = SecureQLabel( - file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX).text() - self.error_status = '' # Hold onto the error status we receive from the Export VM + file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX + ).text() + self.error_status = "" # Hold onto the error status we receive from the Export VM # Connect controller signals to slots self.controller.export.printer_preflight_success.connect(self._on_preflight_success) @@ -2251,30 +2305,33 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Dialog content self.starting_header = _( - 'Preparing to print:' - '
' - '{}'.format(self.file_name)) + "Preparing to print:" + "
" + '{}'.format(self.file_name) + ) self.ready_header = _( - 'Ready to print:' - '
' - '{}'.format(self.file_name)) - self.insert_usb_header = _('Connect USB printer') - self.error_header = _('Printing failed') + "Ready to print:" + "
" + '{}'.format(self.file_name) + ) + self.insert_usb_header = _("Connect USB printer") + self.error_header = _("Printing failed") self.starting_message = _( - '

Managing printout risks

' - 'QR codes and web addresses' - '
' - 'Never type in and open web addresses or scan QR codes contained in printed ' - 'documents without taking security precautions. If you are unsure how to ' - 'manage this risk, please contact your administrator.' - '

' - 'Printer dots' - '
' - 'Any part of a printed page may contain identifying information ' - 'invisible to the naked eye, such as printer dots. Please carefully ' - 'consider this risk when working with or publishing scanned printouts.') - self.insert_usb_message = _('Please connect your printer to a USB port.') - self.generic_error_message = _('See your administrator for help.') + "

Managing printout risks

" + "QR codes and web addresses" + "
" + "Never type in and open web addresses or scan QR codes contained in printed " + "documents without taking security precautions. If you are unsure how to " + "manage this risk, please contact your administrator." + "

" + "Printer dots" + "
" + "Any part of a printed page may contain identifying information " + "invisible to the naked eye, such as printer dots. Please carefully " + "consider this risk when working with or publishing scanned printouts." + ) + self.insert_usb_message = _("Please connect your printer to a USB port.") + self.generic_error_message = _("See your administrator for help.") self._show_starting_instructions() self.start_animate_header() @@ -2297,9 +2354,9 @@ def _show_insert_usb_message(self): def _show_generic_error_message(self): self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self.close) - self.continue_button.setText('DONE') + self.continue_button.setText("DONE") self.header.setText(self.error_header) - self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.body.setText("{}: {}".format(self.error_status, self.generic_error_message)) self.error_details.hide() self.adjustSize() @@ -2316,7 +2373,7 @@ def _print_file(self): def _on_preflight_success(self): # If the continue button is disabled then this is the result of a background preflight check self.stop_animate_header() - self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64)) + self.header_icon.update_image("printer.svg", svg_size=QSize(64, 64)) self.header.setText(self.ready_header) if not self.continue_button.isEnabled(): self.continue_button.clicked.disconnect() @@ -2330,7 +2387,7 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): self.stop_animate_header() - self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64)) + self.header_icon.update_image("printer.svg", svg_size=QSize(64, 64)) self.error_status = error.status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): @@ -2361,8 +2418,9 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = SecureQLabel( - file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX).text() - self.error_status = '' # Hold onto the error status we receive from the Export VM + file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX + ).text() + self.error_status = "" # Hold onto the error status we receive from the Export VM # Connect controller signals to slots self.controller.export.preflight_check_call_success.connect(self._on_preflight_success) @@ -2376,52 +2434,60 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Dialog content self.starting_header = _( - 'Preparing to export:' - '
' - '{}'.format(self.file_name)) + "Preparing to export:" + "
" + '{}'.format(self.file_name) + ) self.ready_header = _( - 'Ready to export:' - '
' - '{}'.format(self.file_name)) - self.insert_usb_header = _('Insert encrypted USB drive') - self.passphrase_header = _('Enter passphrase for USB drive') - self.success_header = _('Export successful') - self.error_header = _('Export failed') + "Ready to export:" + "
" + '{}'.format(self.file_name) + ) + self.insert_usb_header = _("Insert encrypted USB drive") + self.passphrase_header = _("Enter passphrase for USB drive") + self.success_header = _("Export successful") + self.error_header = _("Export failed") self.starting_message = _( - '

Understand the risks before exporting files

' - 'Malware' - '
' - 'This workstation lets you open files securely. If you open files on another ' - 'computer, any embedded malware may spread to your computer or network. If you are ' - 'unsure how to manage this risk, please print the file, or contact your ' - 'administrator.' - '

' - 'Anonymity' - '
' - 'Files submitted by sources may contain information or hidden metadata that ' - 'identifies who they are. To protect your sources, please consider redacting files ' - 'before working with them on network-connected computers.') - self.exporting_message = _('Exporting: {}'.format(self.file_name)) + "

Understand the risks before exporting files

" + "Malware" + "
" + "This workstation lets you open files securely. If you open files on another " + "computer, any embedded malware may spread to your computer or network. If you are " + "unsure how to manage this risk, please print the file, or contact your " + "administrator." + "

" + "Anonymity" + "
" + "Files submitted by sources may contain information or hidden metadata that " + "identifies who they are. To protect your sources, please consider redacting files " + "before working with them on network-connected computers." + ) + self.exporting_message = _("Exporting: {}".format(self.file_name)) self.insert_usb_message = _( - 'Please insert one of the export drives provisioned specifically ' - 'for the SecureDrop Workstation.') + "Please insert one of the export drives provisioned specifically " + "for the SecureDrop Workstation." + ) self.usb_error_message = _( - 'Either the drive is not encrypted or there is something else wrong with it.') - self.passphrase_error_message = _('The passphrase provided did not work. Please try again.') - self.generic_error_message = _('See your administrator for help.') + "Either the drive is not encrypted or there is something else wrong with it." + ) + self.passphrase_error_message = _("The passphrase provided did not work. Please try again.") + self.generic_error_message = _("See your administrator for help.") self.continue_disabled_message = _( - 'The CONTINUE button will be disabled until the Export VM is ready') + "The CONTINUE button will be disabled until the Export VM is ready" + ) self.success_message = _( - 'Remember to be careful when working with files outside of your Workstation machine.') + "Remember to be careful when working with files outside of your Workstation machine." + ) # Passphrase Form self.passphrase_form = QWidget() - self.passphrase_form.setObjectName('ExportDialog_passphrase_form') + self.passphrase_form.setObjectName("ExportDialog_passphrase_form") passphrase_form_layout = QVBoxLayout() passphrase_form_layout.setContentsMargins( - self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN) + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) self.passphrase_form.setLayout(passphrase_form_layout) - passphrase_label = SecureQLabel(_('Passphrase')) + passphrase_label = SecureQLabel(_("Passphrase")) font = QFont() font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) passphrase_label.setFont(font) @@ -2430,7 +2496,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): effect = QGraphicsDropShadowEffect(self) effect.setOffset(0, -1) effect.setBlurRadius(4) - effect.setColor(QColor('#aaa')) + effect.setColor(QColor("#aaa")) self.passphrase_field.setGraphicsEffect(effect) passphrase_form_layout.addWidget(passphrase_label) passphrase_form_layout.addWidget(self.passphrase_field) @@ -2450,7 +2516,7 @@ def _show_passphrase_request_message(self): self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) - self.continue_button.setText('SUBMIT') + self.continue_button.setText("SUBMIT") self.header_line.hide() self.error_details.hide() self.body.hide() @@ -2463,7 +2529,7 @@ def _show_passphrase_request_message_again(self): self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) - self.continue_button.setText('SUBMIT') + self.continue_button.setText("SUBMIT") self.header_line.hide() self.body.hide() self.error_details.show() @@ -2475,7 +2541,7 @@ def _show_success_message(self): self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self.close) self.header.setText(self.success_header) - self.continue_button.setText('DONE') + self.continue_button.setText("DONE") self.body.setText(self.success_message) self.cancel_button.hide() self.error_details.hide() @@ -2488,7 +2554,7 @@ def _show_insert_usb_message(self): self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) - self.continue_button.setText('CONTINUE') + self.continue_button.setText("CONTINUE") self.body.setText(self.insert_usb_message) self.error_details.hide() self.passphrase_form.hide() @@ -2501,7 +2567,7 @@ def _show_insert_encrypted_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) - self.continue_button.setText('CONTINUE') + self.continue_button.setText("CONTINUE") self.body.setText(self.insert_usb_message) self.passphrase_form.hide() self.header_line.show() @@ -2512,9 +2578,9 @@ def _show_insert_encrypted_usb_message(self): def _show_generic_error_message(self): self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self.close) - self.continue_button.setText('DONE') + self.continue_button.setText("DONE") self.header.setText(self.error_header) - self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.body.setText("{}: {}".format(self.error_status, self.generic_error_message)) self.error_details.hide() self.passphrase_form.hide() self.header_line.show() @@ -2536,7 +2602,7 @@ def _export_file(self, checked: bool = False): def _on_preflight_success(self): # If the continue button is disabled then this is the result of a background preflight check self.stop_animate_header() - self.header_icon.update_image('savetodisk.svg', QSize(64, 64)) + self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) self.header.setText(self.ready_header) if not self.continue_button.isEnabled(): self.continue_button.clicked.disconnect() @@ -2550,7 +2616,7 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): self.stop_animate_header() - self.header_icon.update_image('savetodisk.svg', QSize(64, 64)) + self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) self._update_dialog(error.status) @pyqtSlot() @@ -2603,11 +2669,11 @@ def __init__(self): self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setWidgetResizable(True) - self.setObjectName('ConversationScrollArea') + self.setObjectName("ConversationScrollArea") # Create the scroll area's widget conversation = QWidget() - conversation.setObjectName('ConversationScrollArea_conversation') + conversation.setObjectName("ConversationScrollArea_conversation") conversation.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.conversation_layout = QVBoxLayout() conversation.setLayout(self.conversation_layout) @@ -2620,15 +2686,15 @@ def __init__(self): def add_widget_to_conversation( self, index: int, widget: QWidget, alignment_flag: Qt.AlignmentFlag ) -> None: - ''' + """ Add `widget` to the scroll area's widget layout. - ''' + """ self.conversation_layout.insertWidget(index, widget, alignment=alignment_flag) def remove_widget_from_conversation(self, widget: QWidget) -> None: - ''' + """ Remove `widget` from the scroll area's widget layout. - ''' + """ self.conversation_layout.removeWidget(widget) @@ -2711,8 +2777,9 @@ def update_conversation(self, collection: list) -> None: # Check if text in item has changed, then update the # widget to reflect this change. if not isinstance(item_widget, FileWidget): - if (item_widget.message.text() != conversation_item.content) and \ - conversation_item.content: + if ( + item_widget.message.text() != conversation_item.content + ) and conversation_item.content: item_widget.message.setText(conversation_item.content) else: # add a new item to be displayed. @@ -2729,7 +2796,7 @@ def update_conversation(self, collection: list) -> None: # by another user (a journalist using the Web UI is able to delete individual # submissions). for item_widget in current_conversation.values(): - logger.debug('Deleting item: {}'.format(item_widget.uuid)) + logger.debug("Deleting item: {}".format(item_widget.uuid)) self.current_messages.pop(item_widget.uuid) item_widget.deleteLater() self.scroll.remove_widget_from_conversation(item_widget) @@ -2738,7 +2805,7 @@ def add_file(self, file: File, index): """ Add a file from the source. """ - logger.debug('Adding file for {}'.format(file.uuid)) + logger.debug("Adding file for {}".format(file.uuid)) conversation_item = FileWidget( file.uuid, self.controller, @@ -2782,9 +2849,9 @@ def add_reply(self, reply: Union[DraftReply, Reply], index) -> None: try: send_status = reply.send_status.name except AttributeError: - send_status = 'SUCCEEDED' + send_status = "SUCCEEDED" - logger.debug('adding reply: with status {}'.format(send_status)) + logger.debug("adding reply: with status {}".format(send_status)) conversation_item = ReplyWidget( reply.uuid, str(reply), @@ -2807,12 +2874,13 @@ def add_reply_from_reply_box(self, uuid: str, content: str) -> None: conversation_item = ReplyWidget( uuid, content, - 'PENDING', + "PENDING", self.controller.reply_ready, self.controller.reply_download_failed, self.controller.reply_succeeded, self.controller.reply_failed, - index) + index, + ) self.scroll.add_widget_to_conversation(index, conversation_item, Qt.AlignRight) self.current_messages[uuid] = conversation_item @@ -2850,8 +2918,8 @@ def __init__(self, source: Source, controller: Controller) -> None: self.conversation_title_bar = SourceProfileShortWidget(source, controller) self.conversation_view = ConversationView(source, controller) self.reply_box = ReplyBoxWidget(source, controller) - self.waiting_delete_confirmation = QLabel('Deleting...') - self.waiting_delete_confirmation.setObjectName('SourceConversationWrapper_source_deleted') + self.waiting_delete_confirmation = QLabel("Deleting...") + self.waiting_delete_confirmation.setObjectName("SourceConversationWrapper_source_deleted") self.waiting_delete_confirmation.hide() # Add widgets @@ -2863,7 +2931,8 @@ def __init__(self, source: Source, controller: Controller) -> None: # Connect reply_box to conversation_view self.reply_box.reply_sent.connect(self.conversation_view.on_reply_sent) self.conversation_view.conversation_updated.connect( - self.conversation_title_bar.update_timestamp) + self.conversation_title_bar.update_timestamp + ) @pyqtSlot(str) def _on_source_deleted(self, source_uuid: str): @@ -2896,7 +2965,7 @@ def __init__(self, source: Source, controller: Controller) -> None: self.controller = controller # Set css id - self.setObjectName('ReplyBoxWidget') + self.setObjectName("ReplyBoxWidget") # Set layout main_layout = QVBoxLayout() @@ -2908,11 +2977,11 @@ def __init__(self, source: Source, controller: Controller) -> None: # Create top horizontal line horizontal_line = QWidget() - horizontal_line.setObjectName('ReplyBoxWidget_horizontal_line') + horizontal_line.setObjectName("ReplyBoxWidget_horizontal_line") # Create replybox self.replybox = QWidget() - self.replybox.setObjectName('ReplyBoxWidget_replybox') + self.replybox.setObjectName("ReplyBoxWidget_replybox") replybox_layout = QHBoxLayout(self.replybox) replybox_layout.setContentsMargins(32.6, 19, 27.3, 18) replybox_layout.setSpacing(0) @@ -2923,7 +2992,7 @@ def __init__(self, source: Source, controller: Controller) -> None: # Create reply send button (airplane) self.send_button = QPushButton() self.send_button.clicked.connect(self.send_reply) - button_pixmap = load_image('send.svg') + button_pixmap = load_image("send.svg") button_icon = QIcon(button_pixmap) self.send_button.setIcon(button_icon) self.send_button.setIconSize(QSize(56.5, 47)) @@ -2978,7 +3047,7 @@ def send_reply(self) -> None: reply_text = self.text_edit.toPlainText().strip() if reply_text: self.text_edit.clearFocus() # Fixes #691 - self.text_edit.setText('') + self.text_edit.setText("") reply_uuid = str(uuid4()) self.controller.send_reply(self.source.uuid, reply_uuid, reply_text) self.reply_sent.emit(self.source.uuid, reply_uuid, reply_text) @@ -3002,9 +3071,9 @@ def _on_synced(self, data: str) -> None: try: self.update_authentication_state(self.controller.is_authenticated) - if data == 'syncing' and self.text_edit.hasFocus(): + if data == "syncing" and self.text_edit.hasFocus(): self.refocus_after_sync = True - elif data == 'synced' and self.refocus_after_sync: + elif data == "synced" and self.refocus_after_sync: self.text_edit.setFocus() else: self.refocus_after_sync = False @@ -3025,7 +3094,7 @@ def __init__(self, source, controller): self.controller = controller self.source = source - self.setObjectName('ReplyTextEdit') + self.setObjectName("ReplyTextEdit") self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setTabChangesFocus(True) # Needed so we can TAB to send button. @@ -3079,10 +3148,10 @@ def __init__(self, source_name): self.setLayout(layout) # Signed in - compose_a_reply_to = QLabel(_('Compose a reply to ')) - compose_a_reply_to.setObjectName('ReplyTextEditPlaceholder_text') + compose_a_reply_to = QLabel(_("Compose a reply to ")) + compose_a_reply_to.setObjectName("ReplyTextEditPlaceholder_text") source_name = SecureQLabel(source_name, wordwrap=False) - source_name.setObjectName('ReplyTextEditPlaceholder_bold_blue') + source_name.setObjectName("ReplyTextEditPlaceholder_bold_blue") self.signed_in = QWidget() signed_in_layout = QHBoxLayout() signed_in_layout.setSpacing(0) @@ -3092,10 +3161,10 @@ def __init__(self, source_name): self.signed_in.hide() # Awaiting key - awaiting_key = QLabel('Awaiting encryption key') - awaiting_key.setObjectName('ReplyTextEditPlaceholder_bold_blue') - from_server = QLabel(_(' from server to enable replies')) - from_server.setObjectName('ReplyTextEditPlaceholder_text') + awaiting_key = QLabel("Awaiting encryption key") + awaiting_key.setObjectName("ReplyTextEditPlaceholder_bold_blue") + from_server = QLabel(_(" from server to enable replies")) + from_server.setObjectName("ReplyTextEditPlaceholder_text") self.signed_in_no_key = QWidget() signed_in_no_key_layout = QHBoxLayout() signed_in_no_key_layout.setSpacing(0) @@ -3105,10 +3174,10 @@ def __init__(self, source_name): self.signed_in_no_key.hide() # Signed out - sign_in = QLabel(_('Sign in')) - sign_in.setObjectName('ReplyTextEditPlaceholder_bold_blue') - to_compose_reply = QLabel(' to compose or send a reply') - to_compose_reply.setObjectName('ReplyTextEditPlaceholder_text') + sign_in = QLabel(_("Sign in")) + sign_in.setObjectName("ReplyTextEditPlaceholder_bold_blue") + to_compose_reply = QLabel(" to compose or send a reply") + to_compose_reply.setObjectName("ReplyTextEditPlaceholder_text") self.signed_out = QWidget() signed_out_layout = QHBoxLayout() signed_out_layout.setSpacing(0) @@ -3189,7 +3258,7 @@ def __init__(self, source, controller): self.controller = controller self.source = source - self.setObjectName('SourceMenuButton') + self.setObjectName("SourceMenuButton") self.setIcon(load_icon("ellipsis.svg")) self.setIconSize(QSize(22, 4)) # Set to the size of the svg viewBox @@ -3209,7 +3278,7 @@ def __init__(self, text): super().__init__(_(text)) # Set css id - self.setObjectName('TitleLabel') + self.setObjectName("TitleLabel") class LastUpdatedLabel(QLabel): @@ -3219,7 +3288,7 @@ def __init__(self, last_updated): super().__init__(last_updated) # Set css id - self.setObjectName('LastUpdatedLabel') + self.setObjectName("LastUpdatedLabel") class SourceProfileShortWidget(QWidget): @@ -3248,9 +3317,10 @@ def __init__(self, source, controller): header = QWidget() header_layout = QHBoxLayout(header) header_layout.setContentsMargins( - self.MARGIN_LEFT, self.VERTICAL_MARGIN, self.MARGIN_RIGHT, self.VERTICAL_MARGIN) + self.MARGIN_LEFT, self.VERTICAL_MARGIN, self.MARGIN_RIGHT, self.VERTICAL_MARGIN + ) title = TitleLabel(self.source.journalist_designation) - self.updated = LastUpdatedLabel(_(arrow.get(self.source.last_updated).format('DD MMM'))) + self.updated = LastUpdatedLabel(_(arrow.get(self.source.last_updated).format("DD MMM"))) menu = SourceMenuButton(self.source, self.controller) header_layout.addWidget(title, alignment=Qt.AlignLeft) header_layout.addStretch() @@ -3259,7 +3329,7 @@ def __init__(self, source, controller): # Create horizontal line horizontal_line = QWidget() - horizontal_line.setObjectName('SourceProfileShortWidget_horizontal_line') + horizontal_line.setObjectName("SourceProfileShortWidget_horizontal_line") horizontal_line.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Add widgets @@ -3271,4 +3341,4 @@ def update_timestamp(self): Ensure the timestamp is always kept up to date with the latest activity from the source. """ - self.updated.setText(_(arrow.get(self.source.last_updated).format('DD MMM'))) + self.updated.setText(_(arrow.get(self.source.last_updated).format("DD MMM"))) diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index a2f42062b..ac0fc5768 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -16,33 +16,42 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -import arrow import datetime import functools import inspect import logging import os -import sdclientapi import uuid -from typing import Dict, Tuple, Union, Any, List, Type # noqa: F401 - from gettext import gettext as _ -from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer, QProcess, Qt +from typing import Any, Dict, List, Tuple, Type, Union # noqa: F401 + +import arrow +import sdclientapi +from PyQt5.QtCore import QObject, QProcess, Qt, QThread, QTimer, pyqtSignal from sdclientapi import RequestTimeoutError, ServerConnectionError from sqlalchemy.orm.session import sessionmaker -from securedrop_client import storage -from securedrop_client import db +from securedrop_client import db, storage from securedrop_client.api_jobs.base import ApiInaccessibleError from securedrop_client.api_jobs.downloads import ( - DownloadChecksumMismatchException, DownloadDecryptionException, DownloadException, - FileDownloadJob, MessageDownloadJob, ReplyDownloadJob, + DownloadChecksumMismatchException, + DownloadDecryptionException, + DownloadException, + FileDownloadJob, + MessageDownloadJob, + ReplyDownloadJob, ) from securedrop_client.api_jobs.sources import DeleteSourceJob, DeleteSourceJobException -from securedrop_client.api_jobs.uploads import SendReplyJob, SendReplyJobError, \ - SendReplyJobTimeoutError -from securedrop_client.api_jobs.updatestar import UpdateStarJob, UpdateStarJobError, \ - UpdateStarJobTimeoutError +from securedrop_client.api_jobs.updatestar import ( + UpdateStarJob, + UpdateStarJobError, + UpdateStarJobTimeoutError, +) +from securedrop_client.api_jobs.uploads import ( + SendReplyJob, + SendReplyJobError, + SendReplyJobTimeoutError, +) from securedrop_client.crypto import GpgHelper from securedrop_client.export import Export from securedrop_client.queue import ApiJobQueue @@ -243,11 +252,16 @@ class Controller(QObject): Emits: PyQt_PyObject: the ApiJob to be added """ - add_job = pyqtSignal('PyQt_PyObject') + add_job = pyqtSignal("PyQt_PyObject") def __init__( - self, hostname: str, gui, session_maker: sessionmaker, - home: str, proxy: bool = True, qubes: bool = True + self, + hostname: str, + gui, + session_maker: sessionmaker, + home: str, + proxy: bool = True, + qubes: bool = True, ) -> None: """ The hostname, gui and session objects are used to coordinate with the @@ -296,7 +310,7 @@ def __init__( self.export = Export() # File data. - self.data_dir = os.path.join(self.home, 'data') + self.data_dir = os.path.join(self.home, "data") # Background sync to keep client up-to-date with server changes self.api_sync = ApiSync(self.api, self.session_maker, self.gpg, self.data_dir) @@ -309,7 +323,7 @@ def __init__( self.show_last_sync_timer.timeout.connect(self.show_last_sync) # Path to the file containing the timestamp since the last sync with the server - self.last_sync_filepath = os.path.join(home, 'sync_flag') + self.last_sync_filepath = os.path.join(home, "sync_flag") @property def is_authenticated(self) -> bool: @@ -323,7 +337,7 @@ def is_authenticated(self, is_authenticated: bool) -> None: @is_authenticated.deleter def is_authenticated(self) -> None: - raise AttributeError('Cannot delete is_authenticated') + raise AttributeError("Cannot delete is_authenticated") def setup(self): """ @@ -348,13 +362,15 @@ def setup(self): storage.clear_download_errors(self.session) - def call_api(self, - api_call_func, - success_callback, - failure_callback, - *args, - current_object=None, - **kwargs): + def call_api( + self, + api_call_func, + success_callback, + failure_callback, + *args, + current_object=None, + **kwargs, + ): """ Calls the function in a non-blocking manner. Upon completion calls the callback with the result. Calls timeout if the timer associated with @@ -364,25 +380,26 @@ def call_api(self, new_thread_id = str(uuid.uuid4()) # Uniquely id the new thread. new_api_thread = QThread(self.gui) - new_api_runner = APICallRunner(api_call_func, current_object, *args, - **kwargs) + new_api_runner = APICallRunner(api_call_func, current_object, *args, **kwargs) new_api_runner.moveToThread(new_api_thread) # handle completed call: copy response data, reset the # client, give the user-provided callback the response # data new_api_runner.call_succeeded.connect( - lambda: self.completed_api_call(new_thread_id, success_callback)) + lambda: self.completed_api_call(new_thread_id, success_callback) + ) new_api_runner.call_failed.connect( - lambda: self.completed_api_call(new_thread_id, failure_callback)) + lambda: self.completed_api_call(new_thread_id, failure_callback) + ) # when the thread starts, we want to run `call_api` on `api_runner` new_api_thread.started.connect(new_api_runner.call_api) # Add the thread related objects to the api_threads dictionary. self.api_threads[new_thread_id] = { - 'thread': new_api_thread, - 'runner': new_api_runner, + "thread": new_api_thread, + "runner": new_api_runner, } # Start the thread and related activity. @@ -390,7 +407,8 @@ def call_api(self, def on_queue_paused(self) -> None: self.gui.update_error_status( - _('The SecureDrop server cannot be reached. Trying to reconnect...'), duration=0) + _("The SecureDrop server cannot be reached. Trying to reconnect..."), duration=0 + ) self.show_last_sync_timer.start(TIME_BETWEEN_SHOWING_LAST_SYNC_MS) def resume_queues(self) -> None: @@ -408,11 +426,11 @@ def completed_api_call(self, thread_id, user_callback): """ logger.debug("Completed API call. Cleaning up and running callback.") thread_info = self.api_threads.pop(thread_id) - runner = thread_info['runner'] + runner = thread_info["runner"] result_data = runner.result arg_spec = inspect.getfullargspec(user_callback) - if 'current_object' in arg_spec.args: + if "current_object" in arg_spec.args: user_callback(result_data, current_object=runner.current_object) else: user_callback(result_data) @@ -428,25 +446,27 @@ def login(self, username, password, totp): """ storage.mark_all_pending_drafts_as_failed(self.session) self.api = sdclientapi.API( - self.hostname, username, password, totp, self.proxy, default_request_timeout=60) - self.call_api(self.api.authenticate, - self.on_authenticate_success, - self.on_authenticate_failure) + self.hostname, username, password, totp, self.proxy, default_request_timeout=60 + ) + self.call_api( + self.api.authenticate, self.on_authenticate_success, self.on_authenticate_failure + ) self.show_last_sync_timer.stop() - self.set_status('') + self.set_status("") def on_authenticate_success(self, result): """ Handles a successful authentication call against the API. """ - logger.info('{} successfully logged in'.format(self.api.username)) + logger.info("{} successfully logged in".format(self.api.username)) self.gui.hide_login() user = storage.update_and_get_user( self.api.token_journalist_uuid, self.api.username, self.api.journalist_first_name, self.api.journalist_last_name, - self.session) + self.session, + ) # Clear clipboard contents in case of previously pasted creds self.gui.clear_clipboard() self.gui.show_main_window(user) @@ -458,8 +478,10 @@ def on_authenticate_success(self, result): def on_authenticate_failure(self, result: Exception) -> None: # Failed to authenticate. Reset state with failure message. self.invalidate_token() - error = _('That didn\'t work. Please check everything and try again.\n' - 'Make sure to use a new two-factor code.') + error = _( + "That didn't work. Please check everything and try again.\n" + "Make sure to use a new two-factor code." + ) self.gui.show_login_error(error=error) self.api_sync.stop() @@ -482,7 +504,7 @@ def on_action_requiring_login(self): """ Indicate that a user needs to login to perform the specified action. """ - error = _('You must sign in to perform this action.') + error = _("You must sign in to perform this action.") self.gui.update_error_status(error) def authenticated(self): @@ -503,7 +525,7 @@ def get_last_sync(self): return None def on_sync_started(self) -> None: - self.sync_events.emit('syncing') + self.sync_events.emit("syncing") def on_sync_success(self) -> None: """ @@ -514,18 +536,17 @@ def on_sync_success(self) -> None: * Download new messages and replies * Update missing files so that they can be re-downloaded """ - with open(self.last_sync_filepath, 'w') as f: + with open(self.last_sync_filepath, "w") as f: f.write(arrow.now().format()) missing_files = storage.update_missing_files(self.data_dir, self.session) for missed_file in missing_files: - self.file_missing.emit(missed_file.source.uuid, missed_file.uuid, - str(missed_file)) + self.file_missing.emit(missed_file.source.uuid, missed_file.uuid, str(missed_file)) self.update_sources() self.gui.refresh_current_source_conversation() self.download_new_messages() self.download_new_replies() - self.sync_events.emit('synced') + self.sync_events.emit("synced") self.resume_queues() def on_sync_failure(self, result: Exception) -> None: @@ -534,7 +555,7 @@ def on_sync_failure(self, result: Exception) -> None: a sync fails is ApiInaccessibleError then we need to log the user out for security reasons and show them the login window in order to get a new token. """ - logger.warning('sync failure: {}'.format(result)) + logger.warning("sync failure: {}".format(result)) if isinstance(result, ApiInaccessibleError): # Don't show login window if the user is already logged out @@ -543,10 +564,11 @@ def on_sync_failure(self, result: Exception) -> None: self.invalidate_token() self.logout() - self.gui.show_login(error=_('Your session expired. Please log in again.')) + self.gui.show_login(error=_("Your session expired. Please log in again.")) elif isinstance(result, (RequestTimeoutError, ServerConnectionError)): self.gui.update_error_status( - _('The SecureDrop server cannot be reached. Trying to reconnect...'), duration=0) + _("The SecureDrop server cannot be reached. Trying to reconnect..."), duration=0 + ) def show_last_sync(self): """ @@ -565,11 +587,10 @@ def on_update_star_success(self, source_uuid: str) -> None: self.star_update_successful.emit(source_uuid) def on_update_star_failure( - self, - error: Union[UpdateStarJobError, UpdateStarJobTimeoutError] + self, error: Union[UpdateStarJobError, UpdateStarJobTimeoutError] ) -> None: if isinstance(error, UpdateStarJobError): - self.gui.update_error_status(_('Failed to update star.')) + self.gui.update_error_status(_("Failed to update star.")) source = self.session.query(db.Source).filter_by(uuid=error.source_uuid).one() self.star_update_failed.emit(error.source_uuid, source.is_starred) @@ -620,9 +641,9 @@ def set_status(self, message, duration=5000): self.gui.update_activity_status(message, duration) @login_required - def _submit_download_job(self, - object_type: Union[Type[db.Reply], Type[db.Message], Type[db.File]], - uuid: str) -> None: + def _submit_download_job( + self, object_type: Union[Type[db.Reply], Type[db.Message], Type[db.File]], uuid: str + ) -> None: if object_type == db.Reply: job = ReplyDownloadJob( @@ -645,7 +666,7 @@ def download_new_messages(self) -> None: new_messages = storage.find_new_messages(self.session) new_message_count = len(new_messages) if new_message_count > 0: - self.set_status(_('Retrieving new messages'), 2500) + self.set_status(_("Retrieving new messages"), 2500) for message in new_messages: if message.download_error: @@ -669,7 +690,7 @@ def on_message_download_failure(self, exception: DownloadException) -> None: """ if isinstance(exception, DownloadChecksumMismatchException): # Keep resubmitting the job if the download is corrupted. - logger.warning('Failure due to checksum mismatch, retrying {}'.format(exception.uuid)) + logger.warning("Failure due to checksum mismatch, retrying {}".format(exception.uuid)) self._submit_download_job(exception.object_type, exception.uuid) self.session.commit() @@ -703,7 +724,7 @@ def on_reply_download_failure(self, exception: DownloadException) -> None: """ if isinstance(exception, DownloadChecksumMismatchException): # Keep resubmitting the job if the download is corrupted. - logger.warning('Failure due to checksum mismatch, retrying {}'.format(exception.uuid)) + logger.warning("Failure due to checksum mismatch, retrying {}".format(exception.uuid)) self._submit_download_job(exception.object_type, exception.uuid) self.session.commit() @@ -714,15 +735,19 @@ def on_reply_download_failure(self, exception: DownloadException) -> None: logger.error(f"Could not emit reply_download_failed: {e}") def downloaded_file_exists(self, file: db.File) -> bool: - ''' + """ Check if the file specified by file_uuid exists. If it doesn't update the local db and GUI to show the file as not downloaded. - ''' + """ if not os.path.exists(file.location(self.data_dir)): - self.gui.update_error_status(_( - 'File does not exist in the data directory. Please try re-downloading.')) - logger.warning('Cannot find file in {}. File does not exist.'.format( - os.path.dirname(file.filename))) + self.gui.update_error_status( + _("File does not exist in the data directory. Please try re-downloading.") + ) + logger.warning( + "Cannot find file in {}. File does not exist.".format( + os.path.dirname(file.filename) + ) + ) missing_files = storage.update_missing_files(self.data_dir, self.session) for f in missing_files: self.file_missing.emit(f.source.uuid, f.uuid, str(f)) @@ -730,10 +755,10 @@ def downloaded_file_exists(self, file: db.File) -> bool: return True def on_file_open(self, file: db.File) -> None: - ''' + """ Open the file specified by file_uuid. If the file is missing, update the db so that is_downloaded is set to False. - ''' + """ logger.info('Opening file in "{}".'.format(os.path.dirname(file.location(self.data_dir)))) if not self.downloaded_file_exists(file): @@ -743,15 +768,15 @@ def on_file_open(self, file: db.File) -> None: return command = "qvm-open-in-vm" - args = ['--view-only', '$dispvm:sd-viewer', file.location(self.data_dir)] + args = ["--view-only", "$dispvm:sd-viewer", file.location(self.data_dir)] process = QProcess(self) process.start(command, args) def run_printer_preflight_checks(self): - ''' + """ Run preflight checks to make sure the Export VM is configured correctly. - ''' - logger.info('Running printer preflight check') + """ + logger.info("Running printer preflight check") if not self.qubes: self.export.printer_preflight_success.emit() @@ -760,10 +785,10 @@ def run_printer_preflight_checks(self): self.export.begin_printer_preflight.emit() def run_export_preflight_checks(self): - ''' + """ Run preflight checks to make sure the Export VM is configured correctly. - ''' - logger.info('Running export preflight check') + """ + logger.info("Running export preflight check") if not self.qubes: self.export.preflight_check_call_success.emit() @@ -772,14 +797,14 @@ def run_export_preflight_checks(self): self.export.begin_preflight_check.emit() def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: - ''' + """ Send the file specified by file_uuid to the Export VM with the user-provided passphrase for unlocking the attached transfer device. If the file is missing, update the db so that is_downloaded is set to False. - ''' + """ file = self.get_file(file_uuid) file_location = file.location(self.data_dir) - logger.info('Exporting file in: {}'.format(os.path.dirname(file_location))) + logger.info("Exporting file in: {}".format(os.path.dirname(file_location))) if not self.downloaded_file_exists(file): return @@ -791,13 +816,13 @@ def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: self.export.begin_usb_export.emit([file_location], passphrase) def print_file(self, file_uuid: str) -> None: - ''' + """ Send the file specified by file_uuid to the Export VM. If the file is missing, update the db so that is_downloaded is set to False. - ''' + """ file = self.get_file(file_uuid) file_location = file.location(self.data_dir) - logger.info('Printing file in: {}'.format(os.path.dirname(file_location))) + logger.info("Printing file in: {}".format(os.path.dirname(file_location))) if not self.downloaded_file_exists(file): return @@ -809,9 +834,7 @@ def print_file(self, file_uuid: str) -> None: @login_required def on_submission_download( - self, - submission_type: Union[Type[db.File], Type[db.Message]], - submission_uuid: str, + self, submission_type: Union[Type[db.File], Type[db.Message]], submission_uuid: str, ) -> None: """ Download the file associated with the Submission (which may be a File or Message). @@ -835,14 +858,14 @@ def on_file_download_failure(self, exception: Exception) -> None: """ # Keep resubmitting the job if the download is corrupted. if isinstance(exception, DownloadChecksumMismatchException): - logger.warning('Failure due to checksum mismatch, retrying {}'.format(exception.uuid)) + logger.warning("Failure due to checksum mismatch, retrying {}".format(exception.uuid)) self._submit_download_job(exception.object_type, exception.uuid) else: if isinstance(exception, DownloadDecryptionException): logger.error("Failed to decrypt %s", exception.uuid) f = self.get_file(exception.uuid) self.file_missing.emit(f.source.uuid, f.uuid, str(f)) - self.gui.update_error_status(_('The file download failed. Please try again.')) + self.gui.update_error_status(_("The file download failed. Please try again.")) def on_delete_source_success(self, source_uuid: str) -> None: """ @@ -852,7 +875,7 @@ def on_delete_source_success(self, source_uuid: str) -> None: def on_delete_source_failure(self, e: Exception) -> None: if isinstance(e, DeleteSourceJobException): - error = _('Failed to delete source at server') + error = _("Failed to delete source at server") self.gui.update_error_status(error) self.source_deletion_failed.emit(e.source_uuid) @@ -881,8 +904,11 @@ def send_reply(self, source_uuid: str, reply_uuid: str, message: str) -> None: # Before we send the reply, add the draft to the database with a PENDING # reply send status. source = self.session.query(db.Source).filter_by(uuid=source_uuid).one() - reply_status = self.session.query(db.ReplySendStatus).filter_by( - name=db.ReplySendStatusCodes.PENDING.value).one() + reply_status = ( + self.session.query(db.ReplySendStatus) + .filter_by(name=db.ReplySendStatusCodes.PENDING.value) + .one() + ) draft_reply = db.DraftReply( uuid=reply_uuid, timestamp=datetime.datetime.utcnow(), @@ -902,16 +928,15 @@ def send_reply(self, source_uuid: str, reply_uuid: str, message: str) -> None: self.add_job.emit(job) def on_reply_success(self, reply_uuid: str) -> None: - logger.info('{} sent successfully'.format(reply_uuid)) + logger.info("{} sent successfully".format(reply_uuid)) self.session.commit() reply = storage.get_reply(self.session, reply_uuid) self.reply_succeeded.emit(reply.source.uuid, reply_uuid, reply.content) def on_reply_failure( - self, - exception: Union[SendReplyJobError, SendReplyJobTimeoutError] + self, exception: Union[SendReplyJobError, SendReplyJobTimeoutError] ) -> None: - logger.debug('{} failed to send'.format(exception.reply_uuid)) + logger.debug("{} failed to send".format(exception.reply_uuid)) # only emit failure signal for non-timeout errors if isinstance(exception, SendReplyJobError): @@ -923,7 +948,7 @@ def get_file(self, file_uuid: str) -> db.File: return file def on_logout_success(self, result) -> None: - logging.info('Client logout successful') + logging.info("Client logout successful") def on_logout_failure(self, result: Exception) -> None: - logging.info('Client logout failure') + logging.info("Client logout failure") diff --git a/securedrop_client/queue.py b/securedrop_client/queue.py index e3c047022..6abb0f8e7 100644 --- a/securedrop_client/queue.py +++ b/securedrop_client/queue.py @@ -1,27 +1,33 @@ import itertools import logging import threading - -from PyQt5.QtCore import QObject, QThread, pyqtSlot, pyqtSignal from queue import PriorityQueue +from typing import Optional, Tuple # noqa: F401 + +from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot from sdclientapi import API, RequestTimeoutError, ServerConnectionError from sqlalchemy.orm import scoped_session -from typing import Optional, Tuple # noqa: F401 -from securedrop_client.api_jobs.base import ApiJob, ApiInaccessibleError, DEFAULT_NUM_ATTEMPTS, \ - PauseQueueJob -from securedrop_client.api_jobs.downloads import (FileDownloadJob, MessageDownloadJob, - ReplyDownloadJob) +from securedrop_client.api_jobs.base import ( + DEFAULT_NUM_ATTEMPTS, + ApiInaccessibleError, + ApiJob, + PauseQueueJob, +) +from securedrop_client.api_jobs.downloads import ( + FileDownloadJob, + MessageDownloadJob, + ReplyDownloadJob, +) from securedrop_client.api_jobs.sources import DeleteSourceJob -from securedrop_client.api_jobs.uploads import SendReplyJob from securedrop_client.api_jobs.updatestar import UpdateStarJob - +from securedrop_client.api_jobs.uploads import SendReplyJob logger = logging.getLogger(__name__) class RunnableQueue(QObject): - ''' + """ RunnableQueue maintains a priority queue and processes jobs in that queue. It continuously processes the next job in the queue, which is ordered by highest priority. Priority is based on job type. If multiple jobs of the same type are added to the queue then they are retrieved @@ -41,7 +47,7 @@ class RunnableQueue(QObject): job and continue on to processing the next job. The job itself is responsible for emiting the success and failure signals, so when an unexpected error occurs, it should emit the failure signal so that the Controller can respond accordingly. - ''' + """ # These are the priorities for processing jobs. Lower numbers corresponds to a higher priority. JOB_PRIORITIES = { @@ -84,21 +90,21 @@ def _check_for_duplicate_jobs(self, job: ApiJob) -> bool: in_progress_jobs = [in_progress_job for priority, in_progress_job in self.queue.queue] in_progress_jobs.append(self.current_job) if job in in_progress_jobs: - logger.debug('Duplicate job {}, skipping'.format(job)) + logger.debug("Duplicate job {}, skipping".format(job)) return True return False def add_job(self, job: ApiJob) -> None: - ''' + """ Add the job with its priority to the queue after assigning it the next order_number. Can block while waiting to acquire condition_add_or_remove_job. - ''' + """ with self.condition_add_or_remove_job: if self._check_for_duplicate_jobs(job): return - logger.debug('Added {} to queue'.format(job)) + logger.debug("Added {} to queue".format(job)) current_order_number = next(self.order_number) job.order_number = current_order_number priority = self.JOB_PRIORITIES[type(job)] @@ -106,16 +112,16 @@ def add_job(self, job: ApiJob) -> None: self.condition_add_or_remove_job.notify() def _re_add_job(self, job: ApiJob) -> None: - ''' + """ Reset the job's remaining attempts and put it back into the queue in the order in which it was submitted by the user (do not assign it the next order_number). Used internally. When called condition_add_or_remove_job should be held. - ''' + """ if self._check_for_duplicate_jobs(job): return - logger.debug('Added {} to queue'.format(job)) + logger.debug("Added {} to queue".format(job)) job.remaining_attempts = DEFAULT_NUM_ATTEMPTS priority = self.JOB_PRIORITIES[type(job)] self.queue.put_nowait((priority, job)) @@ -123,7 +129,7 @@ def _re_add_job(self, job: ApiJob) -> None: @pyqtSlot() def process(self) -> None: - ''' + """ Process the next job in the queue. If the job is a PauseQueueJob, emit the paused signal and return from the processing loop so @@ -140,7 +146,7 @@ def process(self) -> None: jobs. Note: Generic exceptions are handled in _do_call_api. - ''' + """ while True: with self.condition_add_or_remove_job: self.condition_add_or_remove_job.wait_for(lambda: not self.queue.empty()) @@ -156,20 +162,20 @@ def process(self) -> None: session = self.session_maker() self.current_job._do_call_api(self.api_client, session) except ApiInaccessibleError as e: - logger.debug('{}: {}'.format(type(e).__name__, e)) + logger.debug("{}: {}".format(type(e).__name__, e)) self.api_client = None with self.condition_add_or_remove_job: self.current_job = None return except (RequestTimeoutError, ServerConnectionError) as e: - logger.debug('{}: {}'.format(type(e).__name__, e)) + logger.debug("{}: {}".format(type(e).__name__, e)) self.add_job(PauseQueueJob()) with self.condition_add_or_remove_job: job, self.current_job = self.current_job, None self._re_add_job(job) except Exception as e: - logger.error('{}: {}'.format(type(e).__name__, e)) - logger.debug('Skipping job') + logger.error("{}: {}".format(type(e).__name__, e)) + logger.debug("Skipping job") finally: with self.condition_add_or_remove_job: self.current_job = None @@ -177,7 +183,7 @@ def process(self) -> None: class ApiJobQueue(QObject): - ''' + """ ApiJobQueue is the queue manager of two FIFO priority queues that process jobs of type ApiJob. @@ -185,7 +191,7 @@ class ApiJobQueue(QObject): make their requests. It stops the queues whenever a MetadataSyncJob, which runs in a continuous loop outside of the queue manager, encounters an ApiInaccessibleError and forces a logout from the Controller. - ''' + """ # Signal that is emitted after a queue is paused. paused = pyqtSignal() @@ -209,52 +215,52 @@ def __init__(self, api_client: API, session_maker: scoped_session) -> None: self.download_file_queue.paused.connect(self.on_file_download_queue_paused) def start(self, api_client: API) -> None: - ''' + """ Start the queues whenever a new api token is provided. - ''' + """ self.main_queue.api_client = api_client self.download_file_queue.api_client = api_client if not self.main_thread.isRunning(): self.main_thread.start() - logger.debug('Started main queue') + logger.debug("Started main queue") if not self.download_file_thread.isRunning(): self.download_file_thread.start() - logger.debug('Started file download queue') + logger.debug("Started file download queue") def stop(self) -> None: - ''' + """ Stop the queues. - ''' + """ if self.main_thread.isRunning(): self.main_thread.quit() - logger.debug('Stopped main queue') + logger.debug("Stopped main queue") if self.download_file_thread.isRunning(): self.download_file_thread.quit() - logger.debug('Stopped file download queue') + logger.debug("Stopped file download queue") @pyqtSlot() def on_main_queue_paused(self) -> None: - ''' + """ Emit the paused signal if the main queue has been paused. - ''' - logger.debug('Paused main queue') + """ + logger.debug("Paused main queue") self.paused.emit() @pyqtSlot() def on_file_download_queue_paused(self) -> None: - ''' + """ Emit the paused signal if the file download queue has been paused. - ''' - logger.debug('Paused file download queue') + """ + logger.debug("Paused file download queue") self.paused.emit() def resume_queues(self) -> None: - ''' + """ Emit the resume signal to the queues if they are running. - ''' + """ if self.main_thread.isRunning(): logger.debug("Resuming main queue") self.main_queue.resume.emit() @@ -264,11 +270,11 @@ def resume_queues(self) -> None: @pyqtSlot(object) def enqueue(self, job: ApiJob) -> None: - ''' + """ Enqueue the supplied job if the queues are running. - ''' + """ if not self.main_thread.isRunning() or not self.download_file_thread.isRunning(): - logger.debug('Not adding job before queues have been started.') + logger.debug("Not adding job before queues have been started.") return if isinstance(job, FileDownloadJob): diff --git a/securedrop_client/resources/__init__.py b/securedrop_client/resources/__init__.py index 4a762dfa6..ba99b507f 100644 --- a/securedrop_client/resources/__init__.py +++ b/securedrop_client/resources/__init__.py @@ -20,13 +20,13 @@ import os from pkg_resources import resource_filename, resource_string -from PyQt5.QtGui import QPixmap, QIcon, QFontDatabase, QMovie -from PyQt5.QtSvg import QSvgWidget from PyQt5.QtCore import QDir +from PyQt5.QtGui import QFontDatabase, QIcon, QMovie, QPixmap +from PyQt5.QtSvg import QSvgWidget # Add resource directories to the search path. -QDir.addSearchPath('images', resource_filename(__name__, 'images')) -QDir.addSearchPath('css', resource_filename(__name__, 'css')) +QDir.addSearchPath("images", resource_filename(__name__, "images")) +QDir.addSearchPath("css", resource_filename(__name__, "css")) def path(name: str, resource_dir: str = "images/") -> str: @@ -39,10 +39,10 @@ def path(name: str, resource_dir: str = "images/") -> str: def load_font(font_folder_name: str) -> None: - directory = resource_filename(__name__, 'fonts/') + font_folder_name + directory = resource_filename(__name__, "fonts/") + font_folder_name for filename in os.listdir(directory): if filename.endswith(".ttf"): - QFontDatabase.addApplicationFont(directory + '/' + filename) + QFontDatabase.addApplicationFont(directory + "/" + filename) def load_icon( @@ -132,7 +132,7 @@ def load_css(name: str) -> str: """ Return the contents of the referenced CSS file in the resources. """ - return resource_string(__name__, "css/" + name).decode('utf-8') + return resource_string(__name__, "css/" + name).decode("utf-8") def load_movie(name: str) -> QMovie: diff --git a/securedrop_client/storage.py b/securedrop_client/storage.py index bd73446b4..9851b6d7d 100644 --- a/securedrop_client/storage.py +++ b/securedrop_client/storage.py @@ -19,27 +19,34 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -from datetime import datetime import logging import os import shutil +from datetime import datetime from pathlib import Path -from dateutil.parser import parse from typing import Any, Dict, List, Tuple, Type, Union +from dateutil.parser import parse +from sdclientapi import API +from sdclientapi import Reply as SDKReply +from sdclientapi import Source as SDKSource +from sdclientapi import Submission as SDKSubmission from sqlalchemy import and_, desc, or_ from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.session import Session -from securedrop_client.db import (DraftReply, Source, Message, File, Reply, ReplySendStatus, - ReplySendStatusCodes, User) +from securedrop_client.db import ( + DraftReply, + File, + Message, + Reply, + ReplySendStatus, + ReplySendStatusCodes, + Source, + User, +) from securedrop_client.utils import SourceCache, chronometer -from sdclientapi import API -from sdclientapi import Source as SDKSource -from sdclientapi import Submission as SDKSubmission -from sdclientapi import Reply as SDKReply - logger = logging.getLogger(__name__) @@ -95,27 +102,28 @@ def get_remote_data(api: API) -> Tuple[List[SDKSource], List[SDKSubmission], Lis remote_submissions = api.get_all_submissions() remote_replies = api.get_all_replies() - logger.info('Fetched {} remote sources.'.format(len(remote_sources))) - logger.info('Fetched {} remote submissions.'.format( - len(remote_submissions))) - logger.info('Fetched {} remote replies.'.format(len(remote_replies))) + logger.info("Fetched {} remote sources.".format(len(remote_sources))) + logger.info("Fetched {} remote submissions.".format(len(remote_submissions))) + logger.info("Fetched {} remote replies.".format(len(remote_replies))) return (remote_sources, remote_submissions, remote_replies) -def update_local_storage(session: Session, - remote_sources: List[SDKSource], - remote_submissions: List[SDKSubmission], - remote_replies: List[SDKReply], - data_dir: str) -> None: +def update_local_storage( + session: Session, + remote_sources: List[SDKSource], + remote_submissions: List[SDKSubmission], + remote_replies: List[SDKReply], + data_dir: str, +) -> None: """ 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. """ - remote_messages = [x for x in remote_submissions if x.filename.endswith('msg.gpg')] - remote_files = [x for x in remote_submissions if not x.filename.endswith('msg.gpg')] + remote_messages = [x for x in remote_submissions if x.filename.endswith("msg.gpg")] + remote_files = [x for x in remote_submissions if not x.filename.endswith("msg.gpg")] # The following update_* functions may change the database state. # Because of that, each get_local_* function needs to be called just before @@ -145,8 +153,7 @@ def lazy_setattr(o: Any, a: str, v: Any) -> None: def update_sources( - remote_sources: List[SDKSource], local_sources: List[Source], - session: Session, data_dir: str + remote_sources: List[SDKSource], local_sources: List[Source], session: Session, data_dir: str ) -> None: """ Given collections of remote sources, the current local sources and a @@ -169,14 +176,14 @@ def update_sources( lazy_setattr(local_source, "document_count", source.number_of_documents) lazy_setattr(local_source, "is_starred", source.is_starred) lazy_setattr(local_source, "last_updated", parse(source.last_updated)) - lazy_setattr(local_source, "public_key", source.key['public']) - lazy_setattr(local_source, "fingerprint", source.key['fingerprint']) + lazy_setattr(local_source, "public_key", source.key["public"]) + lazy_setattr(local_source, "fingerprint", source.key["fingerprint"]) # Removing the UUID from local_sources_by_uuid ensures # this record won't be deleted at the end of this # function. del local_sources_by_uuid[source.uuid] - logger.debug('Updated source {}'.format(source.uuid)) + logger.debug("Updated source {}".format(source.uuid)) else: # A new source to be added to the database. ns = Source( @@ -187,37 +194,48 @@ def update_sources( is_starred=source.is_starred, last_updated=parse(source.last_updated), document_count=source.number_of_documents, - public_key=source.key['public'], - fingerprint=source.key['fingerprint'], + public_key=source.key["public"], + fingerprint=source.key["fingerprint"], ) session.add(ns) - logger.debug('Added new source {}'.format(source.uuid)) + logger.debug("Added new source {}".format(source.uuid)) # The uuids remaining in local_uuids do not exist on the remote server, so # delete the related records. for deleted_source in local_sources_by_uuid.values(): delete_source_collection(deleted_source.journalist_filename, data_dir) session.delete(deleted_source) - logger.debug('Deleted source {}'.format(deleted_source.uuid)) + logger.debug("Deleted source {}".format(deleted_source.uuid)) session.commit() -def update_files(remote_submissions: List[SDKSubmission], local_submissions: List[File], - session: Session, data_dir: str) -> None: +def update_files( + remote_submissions: List[SDKSubmission], + local_submissions: List[File], + session: Session, + data_dir: str, +) -> None: __update_submissions(File, remote_submissions, local_submissions, session, data_dir) -def update_messages(remote_submissions: List[SDKSubmission], local_submissions: List[Message], - session: Session, data_dir: str) -> None: +def update_messages( + remote_submissions: List[SDKSubmission], + local_submissions: List[Message], + session: Session, + data_dir: str, +) -> None: __update_submissions(Message, remote_submissions, local_submissions, session, data_dir) -def __update_submissions(model: Union[Type[File], Type[Message]], - remote_submissions: List[SDKSubmission], - local_submissions: Union[List[Message], List[File]], - session: Session, data_dir: str) -> None: +def __update_submissions( + model: Union[Type[File], Type[Message]], + remote_submissions: List[SDKSubmission], + local_submissions: Union[List[Message], List[File]], + session: Session, + data_dir: str, +) -> None: """ The logic for updating files and messages is effectively the same, so this function is somewhat overloaded to allow us to do both in a DRY way. @@ -244,8 +262,13 @@ def __update_submissions(model: Union[Type[File], Type[Message]], # A new submission to be added to the database. source = source_cache.get(submission.source_uuid) if source: - ns = model(source_id=source.id, uuid=submission.uuid, size=submission.size, - filename=submission.filename, download_url=submission.download_url) + ns = model( + source_id=source.id, + uuid=submission.uuid, + size=submission.size, + filename=submission.filename, + download_url=submission.download_url, + ) session.add(ns) logger.debug(f"Added {model.__name__} {submission.uuid}") @@ -259,8 +282,9 @@ def __update_submissions(model: Union[Type[File], Type[Message]], session.commit() -def update_replies(remote_replies: List[SDKReply], local_replies: List[Reply], - session: Session, data_dir: str) -> None: +def update_replies( + remote_replies: List[SDKReply], local_replies: List[Reply], session: Session, data_dir: str +) -> None: """ * Existing replies are updated in the local database. * New replies have an entry created in the local database. @@ -276,9 +300,7 @@ def update_replies(remote_replies: List[SDKReply], local_replies: List[Reply], for reply in remote_replies: user = users.get(reply.journalist_uuid) if not user: - user = find_or_create_user( - reply.journalist_uuid, reply.journalist_username, session - ) + user = find_or_create_user(reply.journalist_uuid, reply.journalist_username, session) users[reply.journalist_uuid] = user local_reply = local_replies_by_uuid.get(reply.uuid) @@ -288,7 +310,7 @@ def update_replies(remote_replies: List[SDKReply], local_replies: List[Reply], lazy_setattr(local_reply, "filename", reply.filename) del local_replies_by_uuid[reply.uuid] - logger.debug('Updated reply {}'.format(reply.uuid)) + logger.debug("Updated reply {}".format(reply.uuid)) else: # A new reply to be added to the database. source = source_cache.get(reply.source_uuid) @@ -296,36 +318,41 @@ def update_replies(remote_replies: List[SDKReply], local_replies: List[Reply], logger.error(f"No source found for reply {reply.uuid}") continue - nr = Reply(uuid=reply.uuid, - journalist_id=user.id, - source_id=source.id, - filename=reply.filename, - size=reply.size) + nr = Reply( + uuid=reply.uuid, + journalist_id=user.id, + source_id=source.id, + filename=reply.filename, + size=reply.size, + ) session.add(nr) # All replies fetched from the server have succeeded in being sent, # so we should delete the corresponding draft locally if it exists. try: - draft_reply_db_object = session.query(DraftReply).filter_by( - uuid=reply.uuid).one() - - update_draft_replies(session, draft_reply_db_object.source.id, - draft_reply_db_object.timestamp, - draft_reply_db_object.file_counter, - nr.file_counter, commit=False) + draft_reply_db_object = session.query(DraftReply).filter_by(uuid=reply.uuid).one() + + update_draft_replies( + session, + draft_reply_db_object.source.id, + draft_reply_db_object.timestamp, + draft_reply_db_object.file_counter, + nr.file_counter, + commit=False, + ) session.delete(draft_reply_db_object) except NoResultFound: pass # No draft locally stored corresponding to this reply. - logger.debug('Added new reply {}'.format(reply.uuid)) + logger.debug("Added new reply {}".format(reply.uuid)) # The uuids remaining in local_uuids do not exist on the remote server, so # delete the related records. for deleted_reply in local_replies_by_uuid.values(): delete_single_submission_or_reply_on_disk(deleted_reply, data_dir) session.delete(deleted_reply) - logger.debug('Deleted reply {}'.format(deleted_reply.uuid)) + logger.debug("Deleted reply {}".format(deleted_reply.uuid)) session.commit() @@ -354,11 +381,9 @@ def find_or_create_user(uuid: str, username: str, session: Session, commit: bool return user -def update_and_get_user(uuid: str, - username: str, - firstname: str, - lastname: str, - session: Session) -> User: +def update_and_get_user( + uuid: str, username: str, firstname: str, lastname: str, session: Session +) -> User: """ Returns a user object representing the referenced journalist UUID. If user fields have changed, the db is updated. @@ -376,9 +401,9 @@ def update_and_get_user(uuid: str, def update_missing_files(data_dir: str, session: Session) -> List[File]: - ''' + """ Update files that are marked as downloaded yet missing from the filesystem. - ''' + """ files_that_have_been_downloaded = session.query(File).filter_by(is_downloaded=True).all() files_that_are_missing = [] for f in files_that_have_been_downloaded: @@ -389,9 +414,12 @@ def update_missing_files(data_dir: str, session: Session) -> List[File]: def update_draft_replies( - session: Session, source_id: int, timestamp: datetime, - old_file_counter: int, new_file_counter: int, - commit: bool = True + session: Session, + source_id: int, + timestamp: datetime, + old_file_counter: int, + new_file_counter: int, + commit: bool = True, ) -> None: """ When we confirm a sent reply R, if there are drafts that were sent after it, @@ -419,11 +447,17 @@ def update_draft_replies( new_file_counter (int): this is the file_counter of the reply R confirmed as successfully sent from the server. """ - for draft_reply in session.query(DraftReply) \ - .filter(and_(DraftReply.source_id == source_id, - DraftReply.timestamp > timestamp, - DraftReply.file_counter == old_file_counter)) \ - .all(): + for draft_reply in ( + session.query(DraftReply) + .filter( + and_( + DraftReply.source_id == source_id, + DraftReply.timestamp > timestamp, + DraftReply.file_counter == old_file_counter, + ) + ) + .all() + ): draft_reply.file_counter = new_file_counter session.add(draft_reply) if commit: @@ -445,11 +479,15 @@ def find_new_messages(session: Session) -> List[Message]: * The message has not yet had decryption attempted. * Decryption previously failed on a message. """ - q = session.query(Message).join(Source).filter( - or_( - Message.is_downloaded == False, - Message.is_decrypted == False, - Message.is_decrypted == None + q = ( + session.query(Message) + .join(Source) + .filter( + or_( + Message.is_downloaded == False, + Message.is_decrypted == False, + Message.is_decrypted == None, + ) ) ) # noqa: E712 q = q.order_by(desc(Source.last_updated)) @@ -465,11 +503,15 @@ def find_new_replies(session: Session) -> List[Reply]: * The reply has not yet had decryption attempted. * Decryption previously failed on a reply. """ - q = session.query(Reply).join(Source).filter( - or_( - Reply.is_downloaded == False, - Reply.is_decrypted == False, - Reply.is_decrypted == None + q = ( + session.query(Reply) + .join(Source) + .filter( + or_( + Reply.is_downloaded == False, + Reply.is_decrypted == False, + Reply.is_decrypted == None, + ) ) ) # noqa: E712 q = q.order_by(desc(Source.last_updated)) @@ -488,9 +530,7 @@ def mark_as_not_downloaded(uuid: str, session: Session) -> None: def mark_as_downloaded( - model_type: Union[Type[File], Type[Message], Type[Reply]], - uuid: str, - session: Session + model_type: Union[Type[File], Type[Message], Type[Reply]], uuid: str, session: Session ) -> None: """ Mark object as downloaded in the database. @@ -517,7 +557,7 @@ def mark_as_decrypted( uuid: str, session: Session, is_decrypted: bool = True, - original_filename: str = None + original_filename: str = None, ) -> None: """ Mark object as downloaded in the database. @@ -533,10 +573,7 @@ def mark_as_decrypted( def set_message_or_reply_content( - model_type: Union[Type[Message], Type[Reply]], - uuid: str, - content: str, - session: Session + model_type: Union[Type[Message], Type[Reply]], uuid: str, content: str, session: Session ) -> None: """ Mark whether or not the object is decrypted. If it's not decrypted, do not set content. If the @@ -552,13 +589,14 @@ def delete_source_collection(journalist_filename: str, data_dir: str) -> None: source_folder = os.path.join(data_dir, journalist_filename) try: shutil.rmtree(source_folder) - logging.info('Source documents for {} deleted'.format(journalist_filename)) + logging.info("Source documents for {} deleted".format(journalist_filename)) except FileNotFoundError: - logging.info('No source documents for {} to delete'.format(journalist_filename)) + logging.info("No source documents for {} to delete".format(journalist_filename)) -def delete_single_submission_or_reply_on_disk(obj_db: Union[File, Message, Reply], - data_dir: str) -> None: +def delete_single_submission_or_reply_on_disk( + obj_db: Union[File, Message, Reply], data_dir: str +) -> None: """ Delete on disk any files associated with a single submission or reply. """ @@ -566,7 +604,7 @@ def delete_single_submission_or_reply_on_disk(obj_db: Union[File, Message, Reply try: os.remove(obj_db.location(data_dir)) except FileNotFoundError: - logging.info('Object %s already deleted, skipping', obj_db.location(data_dir)) + logging.info("Object %s already deleted, skipping", obj_db.location(data_dir)) if isinstance(obj_db, File): # Also delete the file's enclosing folder. @@ -600,10 +638,12 @@ def mark_all_pending_drafts_as_failed(session: Session) -> List[DraftReply]: """ When we login (offline or online) or logout, we need to set all the pending replies as failed. """ - pending_status = session.query(ReplySendStatus).filter_by( - name=ReplySendStatusCodes.PENDING.value).one() - failed_status = session.query(ReplySendStatus).filter_by( - name=ReplySendStatusCodes.FAILED.value).one() + pending_status = ( + session.query(ReplySendStatus).filter_by(name=ReplySendStatusCodes.PENDING.value).one() + ) + failed_status = ( + session.query(ReplySendStatus).filter_by(name=ReplySendStatusCodes.FAILED.value).one() + ) pending_drafts = session.query(DraftReply).filter_by(send_status=pending_status).all() for pending_draft in pending_drafts: diff --git a/securedrop_client/sync.py b/securedrop_client/sync.py index e6c4b0a75..bbbbbcf71 100644 --- a/securedrop_client/sync.py +++ b/securedrop_client/sync.py @@ -1,21 +1,20 @@ import logging -from PyQt5.QtCore import pyqtSignal, QObject, QThread, QTimer, Qt -from sqlalchemy.orm import scoped_session +from PyQt5.QtCore import QObject, Qt, QThread, QTimer, pyqtSignal from sdclientapi import API +from sqlalchemy.orm import scoped_session from securedrop_client.api_jobs.base import ApiInaccessibleError from securedrop_client.api_jobs.sync import MetadataSyncJob from securedrop_client.crypto import GpgHelper - logger = logging.getLogger(__name__) class ApiSync(QObject): - ''' + """ ApiSync continuously syncs, waiting 15 seconds between task completion. - ''' + """ sync_started = pyqtSignal() sync_success = pyqtSignal() @@ -37,51 +36,52 @@ def __init__( data_dir, self.sync_started, self.on_sync_success, - self.on_sync_failure) + self.on_sync_failure, + ) self.api_sync_bg_task.moveToThread(self.sync_thread) self.sync_thread.started.connect(self.api_sync_bg_task.sync) def start(self, api_client: API) -> None: - ''' + """ Start metadata syncs. - ''' + """ self.api_client = api_client if not self.sync_thread.isRunning(): - logger.debug('Starting sync thread') + logger.debug("Starting sync thread") self.api_sync_bg_task.api_client = self.api_client self.sync_thread.start() def stop(self) -> None: - ''' + """ Stop metadata syncs. - ''' + """ self.api_client = None if self.sync_thread.isRunning(): - logger.debug('Stopping sync thread') + logger.debug("Stopping sync thread") self.sync_thread.quit() def on_sync_success(self) -> None: - ''' + """ Start another sync on success. - ''' + """ self.sync_success.emit() QTimer.singleShot(self.TIME_BETWEEN_SYNCS_MS, self.api_sync_bg_task.sync) def on_sync_failure(self, result: Exception) -> None: - ''' + """ Only start another sync on failure if the reason is a timeout request. - ''' + """ self.sync_failure.emit(result) QTimer.singleShot(self.TIME_BETWEEN_SYNCS_MS, self.api_sync_bg_task.sync) class ApiSyncBackgroundTask(QObject): - ''' + """ ApiSyncBackgroundTask provides a sync method that executes a MetadataSyncJob. - ''' + """ def __init__( self, @@ -91,7 +91,7 @@ def __init__( data_dir: str, sync_started: pyqtSignal, on_sync_success, - on_sync_failure + on_sync_failure, ): super().__init__() @@ -108,9 +108,9 @@ def __init__( self.job.failure_signal.connect(self.on_sync_failure, type=Qt.QueuedConnection) def sync(self) -> None: - ''' + """ Create and run a new MetadataSyncJob. - ''' + """ try: self.sync_started.emit() session = self.session_maker() diff --git a/securedrop_client/utils.py b/securedrop_client/utils.py index d78e83e55..9c568c771 100644 --- a/securedrop_client/utils.py +++ b/securedrop_client/utils.py @@ -2,7 +2,6 @@ import math import os import time - from contextlib import contextmanager from typing import Dict, Generator, Optional @@ -12,9 +11,9 @@ def safe_mkdir(sdc_home: str, relative_path: str = None) -> None: - ''' + """ Safely create directories while checking permissions along the way. - ''' + """ if relative_path: full_path = os.path.join(sdc_home, relative_path) @@ -22,7 +21,7 @@ def safe_mkdir(sdc_home: str, relative_path: str = None) -> None: full_path = sdc_home if not full_path == os.path.abspath(full_path): - raise ValueError('Path is not absolute: {}'.format(full_path)) + raise ValueError("Path is not absolute: {}".format(full_path)) if not os.path.exists(sdc_home): os.makedirs(sdc_home, 0o700) @@ -42,16 +41,15 @@ def safe_mkdir(sdc_home: str, relative_path: str = None) -> None: def check_dir_permissions(dir_path: str) -> None: - ''' + """ Check that a directory has ``700`` as the final 3 bytes. Raises a ``RuntimeError`` otherwise. - ''' + """ if os.path.exists(dir_path): stat_res = os.stat(dir_path).st_mode masked = stat_res & 0o777 if masked & 0o077: - raise RuntimeError('Unsafe permissions ({}) on {}' - .format(oct(stat_res), dir_path)) + raise RuntimeError("Unsafe permissions ({}) on {}".format(oct(stat_res), dir_path)) def split_path(path: str) -> list: @@ -71,11 +69,11 @@ def humanize_filesize(filesize: int) -> str: (with an input unit of bytes) """ if filesize < 1024: - return '{}B'.format(str(filesize)) + return "{}B".format(str(filesize)) elif filesize < 1024 * 1024: - return '{}KB'.format(math.floor(filesize / 1024)) + return "{}KB".format(math.floor(filesize / 1024)) else: - return '{}MB'.format(math.floor(filesize / 1024 ** 2)) + return "{}MB".format(math.floor(filesize / 1024 ** 2)) @contextmanager diff --git a/setup.py b/setup.py index d01c80354..90004641d 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os + import setuptools with open("README.md", "r") as fh: @@ -8,9 +9,8 @@ package_resources = ["securedrop_client/resources/css/sdclient.css"] # All other graphics used in the client -for name in os.listdir('./securedrop_client/resources/images/'): - package_resources.append(os.path.join( - "./securedrop_client/resources/images", name)) +for name in os.listdir("./securedrop_client/resources/images/"): + package_resources.append(os.path.join("./securedrop_client/resources/images", name)) setuptools.setup( name="securedrop-client", @@ -21,12 +21,10 @@ long_description=long_description, long_description_content_type="text/markdown", license="GPLv3+", - install_requires=["SQLALchemy", "alembic", "securedrop-sdk", - "python-dateutil", "arrow"], + install_requires=["SQLALchemy", "alembic", "securedrop-sdk", "python-dateutil", "arrow"], python_requires=">=3.5", url="https://github.com/freedomofpress/securedrop-proxy", - packages=["securedrop_client", "securedrop_client.gui", - "securedrop_client.resources"], + packages=["securedrop_client", "securedrop_client.gui", "securedrop_client.resources"], include_package_data=True, classifiers=( "Development Status :: 3 - Alpha", @@ -36,9 +34,5 @@ "Intended Audience :: Developers", "Operating System :: OS Independent", ), - entry_points={ - 'console_scripts': [ - 'sd-client = securedrop_client.app:run', - ], - }, + entry_points={"console_scripts": ["sd-client = securedrop_client.app:run",],}, ) diff --git a/tests/api_jobs/test_base.py b/tests/api_jobs/test_base.py index 52fe57615..12af9efed 100644 --- a/tests/api_jobs/test_base.py +++ b/tests/api_jobs/test_base.py @@ -1,20 +1,18 @@ import pytest - from sdclientapi import AuthError, RequestTimeoutError, ServerConnectionError -from securedrop_client.api_jobs.base import (ApiInaccessibleError, ApiJob, - SingleObjectApiJob) +from securedrop_client.api_jobs.base import ApiInaccessibleError, ApiJob, SingleObjectApiJob from tests.factory import dummy_job_factory def test_ApiInaccessibleError_init(): # check default value err = ApiInaccessibleError() - assert str(err).startswith('API is inaccessible') + assert str(err).startswith("API is inaccessible") assert isinstance(err, Exception) # check custom - msg = 'foo' + msg = "foo" err = ApiInaccessibleError(msg) assert str(err) == msg @@ -27,7 +25,7 @@ def test_ApiJob_raises_NotImplemetedError(): def test_ApiJob_no_api(mocker): - return_value = 'wat' + return_value = "wat" api_job_cls = dummy_job_factory(mocker, return_value) api_job = api_job_cls() @@ -41,7 +39,7 @@ def test_ApiJob_no_api(mocker): def test_ApiJob_success(mocker): - return_value = 'wat' + return_value = "wat" api_job_cls = dummy_job_factory(mocker, return_value) api_job = api_job_cls() @@ -55,7 +53,7 @@ def test_ApiJob_success(mocker): def test_ApiJob_auth_error(mocker): - return_value = AuthError('oh no') + return_value = AuthError("oh no") api_job_cls = dummy_job_factory(mocker, return_value) api_job = api_job_cls() @@ -107,10 +105,9 @@ def test_ApiJob_retry_suceeds_after_failed_attempt(mocker, exception): """Retry logic: after failed attempt should succeed""" number_of_attempts = 5 - success_return_value = 'now works' + success_return_value = "now works" return_values = [exception(), success_return_value] - api_job_cls = dummy_job_factory(mocker, return_values, - remaining_attempts=number_of_attempts) + api_job_cls = dummy_job_factory(mocker, return_values, remaining_attempts=number_of_attempts) api_job = api_job_cls() mock_api_client = mocker.MagicMock() @@ -129,10 +126,9 @@ def test_ApiJob_retry_exactly_n_attempts_times(mocker, exception): """Retry logic: boundary value case - 5th attempt should succeed""" number_of_attempts = 5 - success_return_value = 'now works' + success_return_value = "now works" return_values = [exception()] * (number_of_attempts - 1) + [success_return_value] - api_job_cls = dummy_job_factory(mocker, return_values, - remaining_attempts=number_of_attempts) + api_job_cls = dummy_job_factory(mocker, return_values, remaining_attempts=number_of_attempts) api_job = api_job_cls() mock_api_client = mocker.MagicMock() @@ -152,8 +148,7 @@ def test_ApiJob_retry_timeout(mocker, exception): number_of_attempts = 5 return_values = [exception()] * (number_of_attempts + 1) - api_job_cls = dummy_job_factory(mocker, return_values, - remaining_attempts=number_of_attempts) + api_job_cls = dummy_job_factory(mocker, return_values, remaining_attempts=number_of_attempts) api_job = api_job_cls() mock_api_client = mocker.MagicMock() @@ -169,7 +164,7 @@ def test_ApiJob_retry_timeout(mocker, exception): def test_ApiJob_comparison(mocker): - return_value = 'wat' + return_value = "wat" api_job_cls = dummy_job_factory(mocker, return_value) api_job_1 = api_job_cls() api_job_1.order_number = 1 @@ -181,7 +176,7 @@ def test_ApiJob_comparison(mocker): def test_ApiJob_order_number_unset(mocker): - return_value = 'wat' + return_value = "wat" api_job_cls = dummy_job_factory(mocker, return_value) api_job_1 = api_job_cls() api_job_2 = api_job_cls() @@ -191,14 +186,14 @@ def test_ApiJob_order_number_unset(mocker): def test_SingleObjectApiJob_comparison_obj_without_uuid_attr(mocker): - test_job_with_uuid = SingleObjectApiJob('uuid1') + test_job_with_uuid = SingleObjectApiJob("uuid1") test_job_without_uuid = ApiJob() assert test_job_with_uuid != test_job_without_uuid def test_SingleObjectApiJob_comparison_obj_with_uuid_attr(mocker): - test_job_with_uuid = SingleObjectApiJob('uuid1') - test_job_with_uuid_2 = SingleObjectApiJob('uuid1') + test_job_with_uuid = SingleObjectApiJob("uuid1") + test_job_with_uuid_2 = SingleObjectApiJob("uuid1") assert test_job_with_uuid == test_job_with_uuid_2 diff --git a/tests/api_jobs/test_downloads.py b/tests/api_jobs/test_downloads.py index 19607ed71..7cb25c678 100644 --- a/tests/api_jobs/test_downloads.py +++ b/tests/api_jobs/test_downloads.py @@ -1,30 +1,34 @@ import os -import pytest from typing import Tuple +import pytest from sdclientapi import BaseError from sdclientapi import Submission as SdkSubmission from securedrop_client.api_jobs.downloads import ( - DownloadJob, FileDownloadJob, MessageDownloadJob, - ReplyDownloadJob, DownloadChecksumMismatchException, DownloadDecryptionException, + DownloadChecksumMismatchException, + DownloadDecryptionException, + DownloadJob, + FileDownloadJob, + MessageDownloadJob, + ReplyDownloadJob, ) -from securedrop_client.crypto import GpgHelper, CryptoError +from securedrop_client.crypto import CryptoError, GpgHelper from tests import factory -with open(os.path.join(os.path.dirname(__file__), '..', 'files', 'test-key.gpg.pub.asc')) as f: +with open(os.path.join(os.path.dirname(__file__), "..", "files", "test-key.gpg.pub.asc")) as f: PUB_KEY = f.read() def patch_decrypt(mocker, homedir, gpghelper, filename): - mock_decrypt = mocker.patch.object(gpghelper, 'decrypt_submission_or_reply') + mock_decrypt = mocker.patch.object(gpghelper, "decrypt_submission_or_reply") fn_no_ext, _ = os.path.splitext(os.path.splitext(os.path.basename(filename))[0]) mock_decrypt.return_value = fn_no_ext return mock_decrypt def test_MessageDownloadJob_raises_NotImplementedError(mocker): - job = DownloadJob('mock', 'uuid') + job = DownloadJob("mock", "uuid") with pytest.raises(NotImplementedError): job.call_download_api(None, None) @@ -41,21 +45,23 @@ def test_ReplyDownloadJob_no_download_or_decrypt(mocker, homedir, session, sessi Test that an already-downloaded reply successfully decrypts. """ reply_is_decrypted_false = factory.Reply( - source=factory.Source(), is_downloaded=True, is_decrypted=False, content=None) + source=factory.Source(), is_downloaded=True, is_decrypted=False, content=None + ) reply_is_decrypted_none = factory.Reply( - source=factory.Source(), is_downloaded=True, is_decrypted=None, content=None) + source=factory.Source(), is_downloaded=True, is_decrypted=None, content=None + ) session.add(reply_is_decrypted_false) session.add(reply_is_decrypted_none) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job_1 = ReplyDownloadJob(reply_is_decrypted_false.uuid, homedir, gpg) job_2 = ReplyDownloadJob(reply_is_decrypted_none.uuid, homedir, gpg) - mocker.patch.object(job_1.gpg, 'decrypt_submission_or_reply') - mocker.patch.object(job_2.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(job_1.gpg, "decrypt_submission_or_reply") + mocker.patch.object(job_2.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - path = os.path.join(homedir, 'data') - api_client.download_submission = mocker.MagicMock(return_value=('', path)) + path = os.path.join(homedir, "data") + api_client.download_submission = mocker.MagicMock(return_value=("", path)) job_1.call_api(api_client, session) job_2.call_api(api_client, session) @@ -77,9 +83,9 @@ def test_ReplyDownloadJob_message_already_decrypted(mocker, homedir, session, se session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = ReplyDownloadJob(reply.uuid, homedir, gpg) - decrypt_fn = mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + decrypt_fn = mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() - download_fn = mocker.patch.object(api_client, 'download_reply') + download_fn = mocker.patch.object(api_client, "download_reply") return_uuid = job.call_api(api_client, session) @@ -97,10 +103,10 @@ def test_ReplyDownloadJob_message_already_downloaded(mocker, homedir, session, s session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = ReplyDownloadJob(reply.uuid, homedir, gpg) - mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - download_fn = mocker.patch.object(api_client, 'download_reply') + download_fn = mocker.patch.object(api_client, "download_reply") return_uuid = job.call_api(api_client, session) @@ -115,16 +121,17 @@ def test_ReplyDownloadJob_happiest_path(mocker, homedir, session, session_maker) keyring. """ reply = factory.Reply( - source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) + source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None + ) session.add(reply) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = ReplyDownloadJob(reply.uuid, homedir, gpg) - mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - data_dir = os.path.join(homedir, 'data') - api_client.download_reply = mocker.MagicMock(return_value=('', data_dir)) + data_dir = os.path.join(homedir, "data") + api_client.download_reply = mocker.MagicMock(return_value=("", data_dir)) job.call_api(api_client, session) @@ -139,21 +146,23 @@ def test_MessageDownloadJob_no_download_or_decrypt(mocker, homedir, session, ses a GPG keyring. """ message_is_decrypted_false = factory.Message( - source=factory.Source(), is_downloaded=True, is_decrypted=False, content=None) + source=factory.Source(), is_downloaded=True, is_decrypted=False, content=None + ) message_is_decrypted_none = factory.Message( - source=factory.Source(), is_downloaded=True, is_decrypted=None, content=None) + source=factory.Source(), is_downloaded=True, is_decrypted=None, content=None + ) session.add(message_is_decrypted_false) session.add(message_is_decrypted_none) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job_1 = MessageDownloadJob(message_is_decrypted_false.uuid, homedir, gpg) job_2 = MessageDownloadJob(message_is_decrypted_none.uuid, homedir, gpg) - mocker.patch.object(job_1.gpg, 'decrypt_submission_or_reply') - mocker.patch.object(job_2.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(job_1.gpg, "decrypt_submission_or_reply") + mocker.patch.object(job_2.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - path = os.path.join(homedir, 'data') - api_client.download_submission = mocker.MagicMock(return_value=('', path)) + path = os.path.join(homedir, "data") + api_client.download_submission = mocker.MagicMock(return_value=("", path)) job_1.call_api(api_client, session) job_2.call_api(api_client, session) @@ -167,7 +176,7 @@ def test_MessageDownloadJob_no_download_or_decrypt(mocker, homedir, session, ses def test_MessageDownloadJob_message_already_decrypted( - mocker, homedir, session, session_maker, download_error_codes + mocker, homedir, session, session_maker, download_error_codes ): """ Test that call_api just returns uuid if already decrypted. @@ -177,10 +186,10 @@ def test_MessageDownloadJob_message_already_decrypted( session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) - decrypt_fn = mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + decrypt_fn = mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - download_fn = mocker.patch.object(api_client, 'download_submission') + download_fn = mocker.patch.object(api_client, "download_submission") return_uuid = job.call_api(api_client, session) @@ -190,7 +199,7 @@ def test_MessageDownloadJob_message_already_decrypted( def test_MessageDownloadJob_message_already_downloaded( - mocker, homedir, session, session_maker, download_error_codes + mocker, homedir, session, session_maker, download_error_codes ): """ Test that call_api just decrypts and returns uuid if already downloaded. @@ -200,10 +209,10 @@ def test_MessageDownloadJob_message_already_downloaded( session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) - mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - download_fn = mocker.patch.object(api_client, 'download_submission') + download_fn = mocker.patch.object(api_client, "download_submission") return_uuid = job.call_api(api_client, session) @@ -218,16 +227,17 @@ def test_MessageDownloadJob_happiest_path(mocker, homedir, session, session_make keyring. """ message = factory.Message( - source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) + source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None + ) session.add(message) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) - mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - data_dir = os.path.join(homedir, 'data') - api_client.download_submission = mocker.MagicMock(return_value=('', data_dir)) + data_dir = os.path.join(homedir, "data") + api_client.download_submission = mocker.MagicMock(return_value=("", data_dir)) job.call_api(api_client, session) @@ -241,15 +251,16 @@ def test_MessageDownloadJob_with_base_error(mocker, homedir, session, session_ma Test when a message does not successfully download. """ message = factory.Message( - source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) + source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None + ) session.add(message) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - mocker.patch.object(api_client, 'download_submission', side_effect=BaseError) - decrypt_fn = mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + mocker.patch.object(api_client, "download_submission", side_effect=BaseError) + decrypt_fn = mocker.patch.object(job.gpg, "decrypt_submission_or_reply") with pytest.raises(BaseError): job.call_api(api_client, session) @@ -261,23 +272,24 @@ def test_MessageDownloadJob_with_base_error(mocker, homedir, session, session_ma def test_MessageDownloadJob_with_crypto_error( - mocker, homedir, session, session_maker, download_error_codes + mocker, homedir, session, session_maker, download_error_codes ): """ Test when a message successfully downloads, but does not successfully decrypt. Use the `homedir` fixture to get a GPG keyring. """ message = factory.Message( - source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None) + source=factory.Source(), is_downloaded=False, is_decrypted=None, content=None + ) session.add(message) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = MessageDownloadJob(message.uuid, homedir, gpg) - mocker.patch.object(job.gpg, 'decrypt_submission_or_reply', side_effect=CryptoError) + mocker.patch.object(job.gpg, "decrypt_submission_or_reply", side_effect=CryptoError) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - path = os.path.join(homedir, 'data') - api_client.download_submission = mocker.MagicMock(return_value=('', path)) + path = os.path.join(homedir, "data") + api_client.download_submission = mocker.MagicMock(return_value=("", path)) with pytest.raises(DownloadDecryptionException): job.call_api(api_client, session) @@ -296,10 +308,10 @@ def test_FileDownloadJob_message_already_decrypted(mocker, homedir, session, ses session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = FileDownloadJob(file_.uuid, homedir, gpg) - decrypt_fn = mocker.patch.object(job.gpg, 'decrypt_submission_or_reply') + decrypt_fn = mocker.patch.object(job.gpg, "decrypt_submission_or_reply") api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - download_fn = mocker.patch.object(api_client, 'download_submission') + download_fn = mocker.patch.object(api_client, "download_submission") return_uuid = job.call_api(api_client, session) @@ -316,11 +328,11 @@ def test_FileDownloadJob_message_already_downloaded(mocker, homedir, session, se session.add(file_) session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) - job = FileDownloadJob(file_.uuid, os.path.join(homedir, 'data'), gpg) + job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg) patch_decrypt(mocker, homedir, gpg, file_.filename) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() - download_fn = mocker.patch.object(api_client, 'download_submission') + download_fn = mocker.patch.object(api_client, "download_submission") return_uuid = job.call_api(api_client, session) @@ -340,37 +352,33 @@ def test_FileDownloadJob_happy_path_no_etag(mocker, homedir, session, session_ma mock_decrypt = patch_decrypt(mocker, homedir, gpg, file_.filename) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: - ''' + """ :return: (etag, path_to_dl) - ''' - full_path = os.path.join(homedir, 'data', 'mock') - with open(full_path, 'wb') as f: - f.write(b'') - return ('', full_path) + """ + full_path = os.path.join(homedir, "data", "mock") + with open(full_path, "wb") as f: + f.write(b"") + return ("", full_path) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download - job = FileDownloadJob( - file_.uuid, - os.path.join(homedir, 'data'), - gpg, - ) + job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg,) - mock_logger = mocker.patch('securedrop_client.api_jobs.downloads.logger') + mock_logger = mocker.patch("securedrop_client.api_jobs.downloads.logger") job.call_api(api_client, session) log_msg = mock_logger.debug.call_args_list[0][0][0] - assert log_msg.startswith('No ETag. Skipping integrity check') + assert log_msg.startswith("No ETag. Skipping integrity check") # ensure mocks aren't stale assert mock_decrypt.called def test_FileDownloadJob_happy_path_sha256_etag( - mocker, homedir, session, session_maker, download_error_codes + mocker, homedir, session, session_maker, download_error_codes ): source = factory.Source() file_ = factory.File(source=source, is_downloaded=None, is_decrypted=None) @@ -382,26 +390,24 @@ def test_FileDownloadJob_happy_path_sha256_etag( mock_decrypt = patch_decrypt(mocker, homedir, gpg, file_.filename) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: - ''' + """ :return: (etag, path_to_dl) - ''' - full_path = os.path.join(homedir, 'data', 'mock') - with open(full_path, 'wb') as f: - f.write(b'wat') + """ + full_path = os.path.join(homedir, "data", "mock") + with open(full_path, "wb") as f: + f.write(b"wat") # sha256 of b'wat' - return ('sha256:f00a787f7492a95e165b470702f4fe9373583fbdc025b2c8bdf0262cc48fcff4', - full_path) + return ( + "sha256:f00a787f7492a95e165b470702f4fe9373583fbdc025b2c8bdf0262cc48fcff4", + full_path, + ) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download - job = FileDownloadJob( - file_.uuid, - os.path.join(homedir, 'data'), - gpg, - ) + job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg,) job.call_api(api_client, session) @@ -410,7 +416,7 @@ def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: def test_FileDownloadJob_bad_sha256_etag( - mocker, homedir, session, session_maker, download_error_codes + mocker, homedir, session, session_maker, download_error_codes ): source = factory.Source() file_ = factory.File(source=source, is_downloaded=None, is_decrypted=None) @@ -421,25 +427,20 @@ def test_FileDownloadJob_bad_sha256_etag( gpg = GpgHelper(homedir, session_maker, is_qubes=False) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: - ''' + """ :return: (etag, path_to_dl) - ''' - full_path = os.path.join(homedir, 'data', 'mock') - with open(full_path, 'wb') as f: - f.write(b'') + """ + full_path = os.path.join(homedir, "data", "mock") + with open(full_path, "wb") as f: + f.write(b"") - return ('sha256:not-a-sha-sum', - full_path) + return ("sha256:not-a-sha-sum", full_path) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download - job = FileDownloadJob( - file_.uuid, - os.path.join(homedir, 'data'), - gpg, - ) + job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg,) with pytest.raises(DownloadChecksumMismatchException): job.call_api(api_client, session) @@ -455,39 +456,34 @@ def test_FileDownloadJob_happy_path_unknown_etag(mocker, homedir, session, sessi gpg = GpgHelper(homedir, session_maker, is_qubes=False) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: - ''' + """ :return: (etag, path_to_dl) - ''' - full_path = os.path.join(homedir, 'data', 'mock') - with open(full_path, 'wb') as f: - f.write(b'') - return ('UNKNOWN:abc123', - full_path) + """ + full_path = os.path.join(homedir, "data", "mock") + with open(full_path, "wb") as f: + f.write(b"") + return ("UNKNOWN:abc123", full_path) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download - job = FileDownloadJob( - file_.uuid, - os.path.join(homedir, 'data'), - gpg, - ) + job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg,) mock_decrypt = patch_decrypt(mocker, homedir, gpg, file_.filename) - mock_logger = mocker.patch('securedrop_client.api_jobs.downloads.logger') + mock_logger = mocker.patch("securedrop_client.api_jobs.downloads.logger") job.call_api(api_client, session) log_msg = mock_logger.debug.call_args_list[0][0][0] - assert log_msg.startswith('Unknown hash algorithm') + assert log_msg.startswith("Unknown hash algorithm") # ensure mocks aren't stale assert mock_decrypt.called def test_FileDownloadJob_decryption_error( - mocker, homedir, session, session_maker, download_error_codes + mocker, homedir, session, session_maker, download_error_codes ): source = factory.Source() file_ = factory.File(source=source, is_downloaded=None, is_decrypted=None) @@ -496,29 +492,27 @@ def test_FileDownloadJob_decryption_error( session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) - mock_decrypt = mocker.patch.object(gpg, 'decrypt_submission_or_reply', side_effect=CryptoError) + mock_decrypt = mocker.patch.object(gpg, "decrypt_submission_or_reply", side_effect=CryptoError) def fake_download(sdk_obj: SdkSubmission, timeout: int) -> Tuple[str, str]: - ''' + """ :return: (etag, path_to_dl) - ''' - full_path = os.path.join(homedir, 'data', 'mock') - with open(full_path, 'wb') as f: - f.write(b'wat') + """ + full_path = os.path.join(homedir, "data", "mock") + with open(full_path, "wb") as f: + f.write(b"wat") # sha256 of b'wat' - return ('sha256:f00a787f7492a95e165b470702f4fe9373583fbdc025b2c8bdf0262cc48fcff4', - full_path) + return ( + "sha256:f00a787f7492a95e165b470702f4fe9373583fbdc025b2c8bdf0262cc48fcff4", + full_path, + ) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() api_client.download_submission = fake_download - job = FileDownloadJob( - file_.uuid, - os.path.join(homedir, 'data'), - gpg, - ) + job = FileDownloadJob(file_.uuid, os.path.join(homedir, "data"), gpg,) with pytest.raises(DownloadDecryptionException): job.call_api(api_client, session) @@ -554,15 +548,15 @@ def test_timeout_length_of_file_downloads(mocker, homedir, session, session_make session.commit() gpg = GpgHelper(homedir, session_maker, is_qubes=False) - zero_byte_file_job = FileDownloadJob(zero_byte_file.uuid, os.path.join(homedir, 'data'), gpg) - one_byte_file_job = FileDownloadJob(one_byte_file.uuid, os.path.join(homedir, 'data'), gpg) - KB_file_job = FileDownloadJob(KB_file.uuid, os.path.join(homedir, 'data'), gpg) - fifty_KB_file_job = FileDownloadJob(fifty_KB_file.uuid, os.path.join(homedir, 'data'), gpg) - half_MB_file_job = FileDownloadJob(half_MB_file.uuid, os.path.join(homedir, 'data'), gpg) - MB_file_job = FileDownloadJob(MB_file.uuid, os.path.join(homedir, 'data'), gpg) - five_MB_file_job = FileDownloadJob(five_MB_file.uuid, os.path.join(homedir, 'data'), gpg) - half_GB_file_job = FileDownloadJob(haf_GB_file.uuid, os.path.join(homedir, 'data'), gpg) - GB_file_job = FileDownloadJob(GB_file.uuid, os.path.join(homedir, 'data'), gpg) + zero_byte_file_job = FileDownloadJob(zero_byte_file.uuid, os.path.join(homedir, "data"), gpg) + one_byte_file_job = FileDownloadJob(one_byte_file.uuid, os.path.join(homedir, "data"), gpg) + KB_file_job = FileDownloadJob(KB_file.uuid, os.path.join(homedir, "data"), gpg) + fifty_KB_file_job = FileDownloadJob(fifty_KB_file.uuid, os.path.join(homedir, "data"), gpg) + half_MB_file_job = FileDownloadJob(half_MB_file.uuid, os.path.join(homedir, "data"), gpg) + MB_file_job = FileDownloadJob(MB_file.uuid, os.path.join(homedir, "data"), gpg) + five_MB_file_job = FileDownloadJob(five_MB_file.uuid, os.path.join(homedir, "data"), gpg) + half_GB_file_job = FileDownloadJob(haf_GB_file.uuid, os.path.join(homedir, "data"), gpg) + GB_file_job = FileDownloadJob(GB_file.uuid, os.path.join(homedir, "data"), gpg) zero_byte_file_timeout = zero_byte_file_job._get_realistic_timeout(zero_byte_file.size) one_byte_file_timeout = one_byte_file_job._get_realistic_timeout(one_byte_file.size) diff --git a/tests/api_jobs/test_sources.py b/tests/api_jobs/test_sources.py index 4eb222c5d..ba3e3f4d6 100644 --- a/tests/api_jobs/test_sources.py +++ b/tests/api_jobs/test_sources.py @@ -1,14 +1,14 @@ import pytest - from sdclientapi import RequestTimeoutError, ServerConnectionError + from securedrop_client.api_jobs.sources import DeleteSourceJob, DeleteSourceJobException from tests import factory def test_delete_source_job(homedir, mocker, session, session_maker): - ''' + """ Test DeleteSourceJob construction and operation. - ''' + """ source = factory.Source() session.add(source) session.commit() @@ -18,8 +18,7 @@ def test_delete_source_job(homedir, mocker, session, session_maker): mock_sdk_source = mocker.Mock() mock_source_init = mocker.patch( - 'securedrop_client.logic.sdclientapi.Source', - return_value=mock_sdk_source + "securedrop_client.logic.sdclientapi.Source", return_value=mock_sdk_source ) job = DeleteSourceJob(source.uuid) @@ -31,9 +30,9 @@ def test_delete_source_job(homedir, mocker, session, session_maker): def test_failure_to_delete(homedir, mocker, session, session_maker): - ''' + """ Check failure of a DeleteSourceJob, which should raise a custom exception. - ''' + """ source = factory.Source() session.add(source) session.commit() @@ -49,10 +48,10 @@ def test_failure_to_delete(homedir, mocker, session, session_maker): @pytest.mark.parametrize("exception", [RequestTimeoutError, ServerConnectionError]) def test_failure_to_delete_timeout(homedir, mocker, session, session_maker, exception): - ''' + """ Check failure of a DeleteSourceJob due to timeouts, which should raise for ApiBase to handle. - ''' + """ source = factory.Source() session.add(source) session.commit() diff --git a/tests/api_jobs/test_sync.py b/tests/api_jobs/test_sync.py index 8cfa72251..d6d586242 100644 --- a/tests/api_jobs/test_sync.py +++ b/tests/api_jobs/test_sync.py @@ -1,11 +1,9 @@ import os from securedrop_client.api_jobs.sync import MetadataSyncJob - from tests import factory - -with open(os.path.join(os.path.dirname(__file__), '..', 'files', 'test-key.gpg.pub.asc')) as f: +with open(os.path.join(os.path.dirname(__file__), "..", "files", "test-key.gpg.pub.asc")) as f: PUB_KEY = f.read() @@ -13,16 +11,12 @@ def test_MetadataSyncJob_success(mocker, homedir, session, session_maker): job = MetadataSyncJob(homedir) mock_source = factory.RemoteSource( - key={ - 'type': 'PGP', - 'public': PUB_KEY, - 'fingerprint': '123456ABC', - } + key={"type": "PGP", "public": PUB_KEY, "fingerprint": "123456ABC",} ) mock_get_remote_data = mocker.patch( - 'securedrop_client.api_jobs.sync.get_remote_data', - return_value=([mock_source], [], [])) + "securedrop_client.api_jobs.sync.get_remote_data", return_value=([mock_source], [], []) + ) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() @@ -39,17 +33,11 @@ def test_MetadataSyncJob_success_with_missing_key(mocker, homedir, session, sess """ job = MetadataSyncJob(homedir) - mock_source = factory.RemoteSource( - key={ - 'type': 'PGP', - 'public': '', - 'fingerprint': '', - } - ) + mock_source = factory.RemoteSource(key={"type": "PGP", "public": "", "fingerprint": "",}) mock_get_remote_data = mocker.patch( - 'securedrop_client.api_jobs.sync.get_remote_data', - return_value=([mock_source], [], [])) + "securedrop_client.api_jobs.sync.get_remote_data", return_value=([mock_source], [], []) + ) api_client = mocker.MagicMock() api_client.default_request_timeout = mocker.MagicMock() diff --git a/tests/api_jobs/test_updatestar.py b/tests/api_jobs/test_updatestar.py index 17c8a798f..5edd59428 100644 --- a/tests/api_jobs/test_updatestar.py +++ b/tests/api_jobs/test_updatestar.py @@ -1,16 +1,18 @@ import pytest - -from securedrop_client.api_jobs.updatestar import UpdateStarJob, UpdateStarJobError, \ - UpdateStarJobTimeoutError from sdclientapi import RequestTimeoutError, ServerConnectionError +from securedrop_client.api_jobs.updatestar import ( + UpdateStarJob, + UpdateStarJobError, + UpdateStarJobTimeoutError, +) from tests import factory def test_star_if_unstar(homedir, mocker, session, session_maker): - ''' + """ Check if we call add_star method if a source is not stared. - ''' + """ source = factory.Source() session.add(source) session.commit() @@ -20,14 +22,12 @@ def test_star_if_unstar(homedir, mocker, session, session_maker): api_client.add_star = mocker.MagicMock() mock_sdk_source = mocker.Mock() - mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', - return_value=mock_sdk_source) - - job = UpdateStarJob( - source.uuid, - source.is_starred + mock_source_init = mocker.patch( + "securedrop_client.logic.sdclientapi.Source", return_value=mock_sdk_source ) + job = UpdateStarJob(source.uuid, source.is_starred) + job.call_api(api_client, session) # ensure we call add_star with right uuid for source @@ -36,9 +36,9 @@ def test_star_if_unstar(homedir, mocker, session, session_maker): def test_unstar_if_star(homedir, mocker, session, session_maker): - ''' + """ Check if we call remove_star method if a source is stared. - ''' + """ source = factory.Source() source.is_starred = True session.add(source) @@ -49,14 +49,12 @@ def test_unstar_if_star(homedir, mocker, session, session_maker): api_client.remove_star = mocker.MagicMock() mock_sdk_source = mocker.Mock() - mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', - return_value=mock_sdk_source) - - job = UpdateStarJob( - source.uuid, - source.is_starred + mock_source_init = mocker.patch( + "securedrop_client.logic.sdclientapi.Source", return_value=mock_sdk_source ) + job = UpdateStarJob(source.uuid, source.is_starred) + job.call_api(api_client, session) # ensure we call remove start wtih right source uuid @@ -65,9 +63,9 @@ def test_unstar_if_star(homedir, mocker, session, session_maker): def test_call_api_raises_UpdateStarJobError(homedir, mocker, session, session_maker): - ''' + """ Check that UpdateStarJobError is raised if remove_star fails due to an exception. - ''' + """ source = factory.Source() source.is_starred = True session.add(source) @@ -78,10 +76,7 @@ def test_call_api_raises_UpdateStarJobError(homedir, mocker, session, session_ma api_client.remove_star = mocker.MagicMock() api_client.remove_star.side_effect = Exception - job = UpdateStarJob( - source.uuid, - source.is_starred - ) + job = UpdateStarJob(source.uuid, source.is_starred) with pytest.raises(UpdateStarJobError): job.call_api(api_client, session) @@ -89,9 +84,9 @@ def test_call_api_raises_UpdateStarJobError(homedir, mocker, session, session_ma @pytest.mark.parametrize("exception", [RequestTimeoutError, ServerConnectionError]) def test_call_api_raises_UpdateStarJobTimeoutError(mocker, session, exception): - ''' + """ Check that UpdateStarJobTimeoutError is raised if remove_star fails due to a timeout. - ''' + """ source = factory.Source() source.is_starred = True session.add(source) @@ -103,6 +98,6 @@ def test_call_api_raises_UpdateStarJobTimeoutError(mocker, session, exception): job = UpdateStarJob(source.uuid, source.is_starred) - error = f'Failed to update star on source {source.uuid} due to error' + error = f"Failed to update star on source {source.uuid} due to error" with pytest.raises(UpdateStarJobTimeoutError, match=error): job.call_api(api_client, session) diff --git a/tests/api_jobs/test_uploads.py b/tests/api_jobs/test_uploads.py index 0395b14c6..96c46fa49 100644 --- a/tests/api_jobs/test_uploads.py +++ b/tests/api_jobs/test_uploads.py @@ -1,28 +1,31 @@ import datetime + import pytest import sdclientapi from securedrop_client import db -from securedrop_client.api_jobs.uploads import SendReplyJob, SendReplyJobError, \ - SendReplyJobTimeoutError -from securedrop_client.crypto import GpgHelper, CryptoError +from securedrop_client.api_jobs.uploads import ( + SendReplyJob, + SendReplyJobError, + SendReplyJobTimeoutError, +) +from securedrop_client.crypto import CryptoError, GpgHelper from tests import factory def test_SendReplyJobTimeoutError(): - error = SendReplyJobTimeoutError('mock_message', 'mock_reply_id') - assert str(error) == 'mock_message' + error = SendReplyJobTimeoutError("mock_message", "mock_reply_id") + assert str(error) == "mock_message" -def test_send_reply_success(homedir, mocker, session, session_maker, - reply_status_codes): - ''' +def test_send_reply_success(homedir, mocker, session, session_maker, reply_status_codes): + """ Check that the "happy path" of encrypting a message and sending it to the server behaves as expected. - ''' + """ source = factory.Source() session.add(source) - msg_uuid = 'xyz456' + msg_uuid = "xyz456" draft_reply = factory.DraftReply(uuid=msg_uuid) session.add(draft_reply) session.commit() @@ -30,27 +33,23 @@ def test_send_reply_success(homedir, mocker, session, session_maker, gpg = GpgHelper(homedir, session_maker, is_qubes=False) api_client = mocker.MagicMock() - api_client.token_journalist_uuid = 'journalist ID sending the reply' + api_client.token_journalist_uuid = "journalist ID sending the reply" - encrypted_reply = 's3kr1t m3ss1dg3' - mock_encrypt = mocker.patch.object(gpg, 'encrypt_to_source', return_value=encrypted_reply) - msg = 'wat' + encrypted_reply = "s3kr1t m3ss1dg3" + mock_encrypt = mocker.patch.object(gpg, "encrypt_to_source", return_value=encrypted_reply) + msg = "wat" - mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename='5-dummy-reply') + mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename="5-dummy-reply") api_client.reply_source = mocker.MagicMock() api_client.reply_source.return_value = mock_reply_response mock_sdk_source = mocker.Mock() - mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', - return_value=mock_sdk_source) - - job = SendReplyJob( - source.uuid, - msg_uuid, - msg, - gpg, + mock_source_init = mocker.patch( + "securedrop_client.logic.sdclientapi.Source", return_value=mock_sdk_source ) + job = SendReplyJob(source.uuid, msg_uuid, msg, gpg,) + job.call_api(api_client, session) # ensure message gets encrypted @@ -62,18 +61,16 @@ def test_send_reply_success(homedir, mocker, session, session_maker, assert reply.journalist_id == api_client.token_journalist_uuid -def test_drafts_ordering(homedir, mocker, session, session_maker, - reply_status_codes): - ''' +def test_drafts_ordering(homedir, mocker, session, session_maker, reply_status_codes): + """ Check that if a reply is successful, drafts sent before and after continue to appear in the same order. - ''' + """ initial_interaction_count = 1 - source_uuid = 'foo' - source = factory.Source(uuid=source_uuid, - interaction_count=initial_interaction_count) + source_uuid = "foo" + source = factory.Source(uuid=source_uuid, interaction_count=initial_interaction_count) session.add(source) - msg_uuid = 'xyz456' + msg_uuid = "xyz456" draft_reply = factory.DraftReply(uuid=msg_uuid, file_counter=1) session.add(draft_reply) @@ -84,7 +81,7 @@ def test_drafts_ordering(homedir, mocker, session, session_maker, timestamp=draft_reply.timestamp - datetime.timedelta(minutes=1), source_id=source.id, file_counter=draft_reply.file_counter, - uuid='foo' + uuid="foo", ) session.add(draft_reply_before) @@ -93,7 +90,7 @@ def test_drafts_ordering(homedir, mocker, session, session_maker, timestamp=draft_reply.timestamp + datetime.timedelta(minutes=1), source_id=source.id, file_counter=draft_reply.file_counter, - uuid='bar' + uuid="bar", ) session.add(draft_reply_after) @@ -102,27 +99,23 @@ def test_drafts_ordering(homedir, mocker, session, session_maker, gpg = GpgHelper(homedir, session_maker, is_qubes=False) api_client = mocker.MagicMock() - api_client.token_journalist_uuid = 'journalist ID sending the reply' + api_client.token_journalist_uuid = "journalist ID sending the reply" - encrypted_reply = 's3kr1t m3ss1dg3' - mock_encrypt = mocker.patch.object(gpg, 'encrypt_to_source', return_value=encrypted_reply) - msg = 'wat' + encrypted_reply = "s3kr1t m3ss1dg3" + mock_encrypt = mocker.patch.object(gpg, "encrypt_to_source", return_value=encrypted_reply) + msg = "wat" - mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename='2-dummy-reply') + mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename="2-dummy-reply") api_client.reply_source = mocker.MagicMock() api_client.reply_source.return_value = mock_reply_response mock_sdk_source = mocker.Mock() - mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', - return_value=mock_sdk_source) - - job = SendReplyJob( - source.uuid, - msg_uuid, - msg, - gpg, + mock_source_init = mocker.patch( + "securedrop_client.logic.sdclientapi.Source", return_value=mock_sdk_source ) + job = SendReplyJob(source.uuid, msg_uuid, msg, gpg,) + job.call_api(api_client, session) # ensure message gets encrypted @@ -152,16 +145,15 @@ def test_drafts_ordering(homedir, mocker, session, session_maker, assert source.collection[2] == draft_reply_after -def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker, - reply_status_codes): - ''' +def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker, reply_status_codes): + """ Check that if gpg fails when sending a message, we do not call the API, and ensure that SendReplyJobError is raised when there is a CryptoError so we can handle it in ApiJob._do_call_api. - ''' + """ source = factory.Source() session.add(source) - msg_uuid = 'xyz456' + msg_uuid = "xyz456" draft_reply = factory.DraftReply(uuid=msg_uuid) session.add(draft_reply) session.commit() @@ -169,26 +161,22 @@ def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker, gpg = GpgHelper(homedir, session_maker, is_qubes=False) api_client = mocker.MagicMock() - api_client.token_journalist_uuid = 'journalist ID sending the reply' + api_client.token_journalist_uuid = "journalist ID sending the reply" - mock_encrypt = mocker.patch.object(gpg, 'encrypt_to_source', side_effect=CryptoError) - msg = 'wat' + mock_encrypt = mocker.patch.object(gpg, "encrypt_to_source", side_effect=CryptoError) + msg = "wat" - mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename='5-dummy-reply') + mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename="5-dummy-reply") api_client.reply_source = mocker.MagicMock() api_client.reply_source.return_value = mock_reply_response mock_sdk_source = mocker.Mock() - mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', - return_value=mock_sdk_source) - - job = SendReplyJob( - source.uuid, - msg_uuid, - msg, - gpg, + mock_source_init = mocker.patch( + "securedrop_client.logic.sdclientapi.Source", return_value=mock_sdk_source ) + job = SendReplyJob(source.uuid, msg_uuid, msg, gpg,) + with pytest.raises(SendReplyJobError): job.call_api(api_client, session) @@ -205,12 +193,13 @@ def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker, assert len(drafts) == 1 -def test_send_reply_sql_exception_during_failure(homedir, mocker, session, session_maker, - reply_status_codes): - ''' +def test_send_reply_sql_exception_during_failure( + homedir, mocker, session, session_maker, reply_status_codes +): + """ Check that we do not raise an unhandled exception when we set the draft reply status to failed in the except block if there is a SQL exception. - ''' + """ source = factory.Source() session.add(source) @@ -219,21 +208,22 @@ def test_send_reply_sql_exception_during_failure(homedir, mocker, session, sessi # expect to be handled. gpg = GpgHelper(homedir, session_maker, is_qubes=False) - job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) + job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) # This should not raise an exception job._set_status_to_failed(session) -def test_send_reply_unexpected_exception_during_failure(homedir, mocker, session, - session_maker, reply_status_codes): - ''' +def test_send_reply_unexpected_exception_during_failure( + homedir, mocker, session, session_maker, reply_status_codes +): + """ Check that we do not raise an unhandled exception when we set the draft reply status to failed in the except block if there is an unexpected exception. - ''' + """ source = factory.Source() session.add(source) - draft_reply = factory.DraftReply(uuid='mock_reply_uuid') + draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() @@ -241,147 +231,153 @@ def test_send_reply_unexpected_exception_during_failure(homedir, mocker, session session.commit = mocker.MagicMock(side_effect=Exception("BOOM")) gpg = GpgHelper(homedir, session_maker, is_qubes=False) - job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) + job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) # This should not raise an exception job._set_status_to_failed(session) -def test_send_reply_failure_unknown_error(homedir, mocker, session, session_maker, - reply_status_codes): - ''' +def test_send_reply_failure_unknown_error( + homedir, mocker, session, session_maker, reply_status_codes +): + """ Check that if the SendReplyJob api call fails when sending a message that SendReplyJobError is raised and the reply is not added to the local database. - ''' + """ source = factory.Source() session.add(source) - draft_reply = factory.DraftReply(uuid='mock_reply_uuid') + draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() - mocker.patch.object(api_client, 'reply_source', side_effect=Exception) + mocker.patch.object(api_client, "reply_source", side_effect=Exception) gpg = GpgHelper(homedir, session_maker, is_qubes=False) - encrypt_fn = mocker.patch.object(gpg, 'encrypt_to_source') - job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) + encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") + job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) with pytest.raises(Exception): job.call_api(api_client, session) - encrypt_fn.assert_called_once_with(source.uuid, 'mock_message') - replies = session.query(db.Reply).filter_by(uuid='mock_reply_uuid').all() + encrypt_fn.assert_called_once_with(source.uuid, "mock_message") + replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db - drafts = session.query(db.DraftReply).filter_by(uuid='mock_reply_uuid').all() + drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1 -@pytest.mark.parametrize("exception", - [sdclientapi.RequestTimeoutError, - sdclientapi.ServerConnectionError]) -def test_send_reply_failure_timeout_error(homedir, mocker, session, session_maker, - reply_status_codes, exception): - ''' +@pytest.mark.parametrize( + "exception", [sdclientapi.RequestTimeoutError, sdclientapi.ServerConnectionError] +) +def test_send_reply_failure_timeout_error( + homedir, mocker, session, session_maker, reply_status_codes, exception +): + """ Check that if the SendReplyJob api call fails because of a RequestTimeoutError or ServerConnectionError that a SendReplyJobTimeoutError is raised. - ''' + """ source = factory.Source() session.add(source) - draft_reply = factory.DraftReply(uuid='mock_reply_uuid') + draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() - mocker.patch.object(api_client, 'reply_source', side_effect=exception) + mocker.patch.object(api_client, "reply_source", side_effect=exception) gpg = GpgHelper(homedir, session_maker, is_qubes=False) - encrypt_fn = mocker.patch.object(gpg, 'encrypt_to_source') - job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) + encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") + job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) with pytest.raises(SendReplyJobTimeoutError): job.call_api(api_client, session) - encrypt_fn.assert_called_once_with(source.uuid, 'mock_message') - replies = session.query(db.Reply).filter_by(uuid='mock_reply_uuid').all() + encrypt_fn.assert_called_once_with(source.uuid, "mock_message") + replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db - drafts = session.query(db.DraftReply).filter_by(uuid='mock_reply_uuid').all() + drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1 -def test_send_reply_failure_when_repr_is_none(homedir, mocker, session, session_maker, - reply_status_codes): - ''' +def test_send_reply_failure_when_repr_is_none( + homedir, mocker, session, session_maker, reply_status_codes +): + """ Check that the SendReplyJob api call results in a SendReplyJobError and nothing else, e.g. no TypeError, when an api call results in an exception that returns None for __repr__ (regression test). - ''' + """ + class MockException(Exception): def __repr__(self): return None - source = factory.Source(uuid='mock_reply_uuid') + source = factory.Source(uuid="mock_reply_uuid") session.add(source) - draft_reply = factory.DraftReply(uuid='mock_reply_uuid') + draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() - mocker.patch.object(api_client, 'reply_source', side_effect=MockException('mock')) + mocker.patch.object(api_client, "reply_source", side_effect=MockException("mock")) gpg = GpgHelper(homedir, session_maker, is_qubes=False) - encrypt_fn = mocker.patch.object(gpg, 'encrypt_to_source') - job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) + encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") + job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) - error = 'Failed to send reply mock_reply_uuid for source {} due to Exception: mock'.format( - source.uuid) + error = "Failed to send reply mock_reply_uuid for source {} due to Exception: mock".format( + source.uuid + ) with pytest.raises(SendReplyJobError, match=error): job.call_api(api_client, session) - encrypt_fn.assert_called_once_with(source.uuid, 'mock_message') - replies = session.query(db.Reply).filter_by(uuid='mock_reply_uuid').all() + encrypt_fn.assert_called_once_with(source.uuid, "mock_message") + replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db - drafts = session.query(db.DraftReply).filter_by(uuid='mock_reply_uuid').all() + drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1 def test_reply_already_sent(homedir, mocker, session, session_maker, reply_status_codes): - ''' + """ Check that if a reply is already sent then we return the reply id. - ''' + """ source = factory.Source() session.add(source) reply = factory.Reply(source=source) session.add(reply) session.commit() - job = SendReplyJob('mock_source_id', reply.uuid, 'mock reply message', mocker.MagicMock()) + job = SendReplyJob("mock_source_id", reply.uuid, "mock reply message", mocker.MagicMock()) reply_uuid = job.call_api(mocker.MagicMock(), session) assert reply.uuid == reply_uuid def test_reply_deleted_locally(homedir, mocker, session, session_maker, reply_status_codes): - ''' + """ Check that if a draft does not exist then we raise a SendReplyJobException so that the job is skipped. - ''' - job = SendReplyJob('mock_source_id', 'mock_uuid', 'mock reply message', mocker.MagicMock()) - error = 'Failed to send reply mock_uuid for source mock_source_id due to Exception' + """ + job = SendReplyJob("mock_source_id", "mock_uuid", "mock reply message", mocker.MagicMock()) + error = "Failed to send reply mock_uuid for source mock_source_id due to Exception" with pytest.raises(SendReplyJobError, match=error): job.call_api(mocker.MagicMock(), session) def test_source_deleted_locally(homedir, mocker, session, session_maker, reply_status_codes): - ''' + """ Check that if a source has been deleted then raise a SendReplyJobException so that the job is skipped. - ''' + """ draft = factory.DraftReply() session.add(draft) session.commit() - job = SendReplyJob('nonexistent_id', draft.uuid, 'mock reply message', mocker.MagicMock()) - error = 'Failed to send reply {} for source {} due to Exception'.format( - draft.uuid, 'nonexistent_id') + job = SendReplyJob("nonexistent_id", draft.uuid, "mock reply message", mocker.MagicMock()) + error = "Failed to send reply {} for source {} due to Exception".format( + draft.uuid, "nonexistent_id" + ) with pytest.raises(SendReplyJobError, match=error): job.call_api(mocker.MagicMock(), session) diff --git a/tests/conftest.py b/tests/conftest.py index aa9a907cb..87297877a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,26 +1,29 @@ import json import os -import tempfile -import pytest import subprocess - +import tempfile from configparser import ConfigParser from datetime import datetime +from uuid import uuid4 +import pytest from PyQt5.QtCore import Qt -from securedrop_client.gui.main import Window -from securedrop_client.logic import Controller -from securedrop_client.config import Config from securedrop_client.app import configure_locale_and_language +from securedrop_client.config import Config from securedrop_client.db import ( - Base, DownloadError, DownloadErrorCodes, ReplySendStatus, - ReplySendStatusCodes, Source, make_session_maker + Base, + DownloadError, + DownloadErrorCodes, + ReplySendStatus, + ReplySendStatusCodes, + Source, + make_session_maker, ) -from uuid import uuid4 - +from securedrop_client.gui.main import Window +from securedrop_client.logic import Controller -with open(os.path.join(os.path.dirname(__file__), 'files', 'test-key.gpg.pub.asc')) as f: +with open(os.path.join(os.path.dirname(__file__), "files", "test-key.gpg.pub.asc")) as f: PUB_KEY = f.read() @@ -42,30 +45,30 @@ TIME_FILE_DOWNLOAD = 5000 -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def i18n(): - ''' + """ Set up locale/language/gettext functions. This enables the use of _(). - ''' + """ configure_locale_and_language() -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def homedir(i18n): - ''' + """ Create a "homedir" for a client. Using `mkdtemp` and not `TemporaryDirectory` because the latter will remove the directory when the object is destroyed, and we want to leave it on the file system so developers can inspect the contents for debugging purposes. - ''' + """ - tmpdir = tempfile.mkdtemp(prefix='sdc-') + tmpdir = tempfile.mkdtemp(prefix="sdc-") os.chmod(tmpdir, 0o0700) - data_dir = os.path.join(tmpdir, 'data') - gpg_dir = os.path.join(tmpdir, 'gpg') - logs_dir = os.path.join(tmpdir, 'logs') + data_dir = os.path.join(tmpdir, "data") + gpg_dir = os.path.join(tmpdir, "gpg") + logs_dir = os.path.join(tmpdir, "logs") for dir_ in [data_dir, gpg_dir, logs_dir]: os.mkdir(dir_) @@ -74,13 +77,13 @@ def homedir(i18n): yield tmpdir -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def functional_test_logged_out_context(homedir, reply_status_codes, session, config): - ''' + """ Returns a tuple containing a Window instance and a Controller instance that have been correctly set up and isolated from any other instances of the application to be run in the test suite. - ''' + """ gui = Window() # Configure test keys. @@ -90,15 +93,14 @@ def functional_test_logged_out_context(homedir, reply_status_codes, session, con session_maker = make_session_maker(homedir) # Create the controller. - controller = Controller(HOSTNAME, gui, session_maker, homedir, - False, False) + controller = Controller(HOSTNAME, gui, session_maker, homedir, False, False) # Link the gui and controller together. gui.controller = controller # Et Voila... return (gui, controller, homedir) -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def functional_test_logged_in_context(functional_test_logged_out_context, qtbot): """ Returns a tuple containing a Window and Controller instance that have been @@ -119,45 +121,44 @@ def wait_for_login(): return (gui, controller, homedir) -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def config(homedir) -> str: full_path = os.path.join(homedir, Config.CONFIG_NAME) - with open(full_path, 'w') as f: - f.write(json.dumps({ - 'journalist_key_fingerprint': '65A1B5FF195B56353CC63DFFCC40EF1228271441', - })) + with open(full_path, "w") as f: + f.write( + json.dumps({"journalist_key_fingerprint": "65A1B5FF195B56353CC63DFFCC40EF1228271441",}) + ) return full_path -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def alembic_config(homedir): return _alembic_config(homedir) def _alembic_config(homedir): - base_dir = os.path.join(os.path.dirname(__file__), '..') - migrations_dir = os.path.join(base_dir, 'alembic') + base_dir = os.path.join(os.path.dirname(__file__), "..") + migrations_dir = os.path.join(base_dir, "alembic") ini = ConfigParser() - ini.read(os.path.join(base_dir, 'alembic.ini')) + ini.read(os.path.join(base_dir, "alembic.ini")) - ini.set('alembic', 'script_location', os.path.join(migrations_dir)) - ini.set('alembic', 'sqlalchemy.url', - 'sqlite:///{}'.format(os.path.join(homedir, 'svs.sqlite'))) + ini.set("alembic", "script_location", os.path.join(migrations_dir)) + ini.set("alembic", "sqlalchemy.url", "sqlite:///{}".format(os.path.join(homedir, "svs.sqlite"))) - alembic_path = os.path.join(homedir, 'alembic.ini') + alembic_path = os.path.join(homedir, "alembic.ini") - with open(alembic_path, 'w') as f: + with open(alembic_path, "w") as f: ini.write(f) return alembic_path -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def session_maker(homedir): return make_session_maker(homedir) -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def session(session_maker): sess = session_maker Base.metadata.create_all(bind=sess.get_bind(), checkfirst=False) @@ -165,7 +166,7 @@ def session(session_maker): sess.close() -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def reply_status_codes(session) -> None: for reply_send_status in ReplySendStatusCodes: reply_status = ReplySendStatus(reply_send_status.value) @@ -174,7 +175,7 @@ def reply_status_codes(session) -> None: return -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def download_error_codes(session) -> None: for download_error_code in DownloadErrorCodes: download_error = DownloadError(download_error_code.name) @@ -183,24 +184,26 @@ def download_error_codes(session) -> None: return -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def source(session) -> dict: args = { - 'uuid': str(uuid4()), - 'public_key': PUB_KEY, + "uuid": str(uuid4()), + "public_key": PUB_KEY, } - source = Source(journalist_designation='foo-bar', - is_flagged=False, - interaction_count=0, - is_starred=False, - last_updated=datetime.now(), - document_count=0, - **args) - args['fingerprint'] = source.fingerprint = 'B2FF7FB28EED8CABEBC5FB6C6179D97BCFA52E5F' + source = Source( + journalist_designation="foo-bar", + is_flagged=False, + interaction_count=0, + is_starred=False, + last_updated=datetime.now(), + document_count=0, + **args + ) + args["fingerprint"] = source.fingerprint = "B2FF7FB28EED8CABEBC5FB6C6179D97BCFA52E5F" session.add(source) session.commit() - args['id'] = source.id - args['source'] = source + args["id"] = source.id + args["source"] = source return args @@ -211,14 +214,13 @@ def create_gpg_test_context(sdc_home): """ gpg_home = os.path.join(sdc_home, "gpg") func_test_path = os.path.dirname(os.path.abspath(__file__)) - key_file = os.path.join(func_test_path, "files", - "securedrop.gpg.asc") + key_file = os.path.join(func_test_path, "files", "securedrop.gpg.asc") cmd = [ - 'gpg', - '--homedir', + "gpg", + "--homedir", gpg_home, - '--allow-secret-key-import', - '--import', + "--allow-secret-key-import", + "--import", os.path.abspath(key_file), ] result = subprocess.run(cmd) diff --git a/tests/factory.py b/tests/factory.py index 46d01be02..89598d293 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,13 +1,14 @@ """Create models with a set of default valid properties, to avoid changes forcing an update of all test code. """ +import os +import uuid from datetime import datetime from itertools import cycle -import os from typing import List -import uuid -from sdclientapi import Reply as SDKReply, Source as SDKSource +from sdclientapi import Reply as SDKReply +from sdclientapi import Source as SDKSource from securedrop_client import db from securedrop_client.api_jobs.base import ApiJob @@ -25,10 +26,10 @@ def User(**attrs): global USER_COUNT USER_COUNT += 1 defaults = dict( - uuid='user-uuid-{}'.format(USER_COUNT), - username='test-user-id-{}'.format(USER_COUNT), - firstname='slim', - lastname='shady' + uuid="user-uuid-{}".format(USER_COUNT), + username="test-user-id-{}".format(USER_COUNT), + firstname="slim", + lastname="shady", ) defaults.update(attrs) @@ -38,21 +39,21 @@ def User(**attrs): def Source(**attrs): - with open(os.path.join(os.path.dirname(__file__), 'files', 'test-key.gpg.pub.asc')) as f: + with open(os.path.join(os.path.dirname(__file__), "files", "test-key.gpg.pub.asc")) as f: pub_key = f.read() global SOURCE_COUNT SOURCE_COUNT += 1 defaults = dict( - uuid='source-uuid-{}'.format(SOURCE_COUNT), - journalist_designation='testy-mctestface', + uuid="source-uuid-{}".format(SOURCE_COUNT), + journalist_designation="testy-mctestface", is_flagged=False, public_key=pub_key, - fingerprint='B2FF7FB28EED8CABEBC5FB6C6179D97BCFA52E5F', + fingerprint="B2FF7FB28EED8CABEBC5FB6C6179D97BCFA52E5F", interaction_count=0, is_starred=False, last_updated=datetime.now(), - document_count=0 + document_count=0, ) defaults.update(attrs) @@ -64,13 +65,13 @@ def Message(**attrs): global MESSAGE_COUNT MESSAGE_COUNT += 1 defaults = dict( - uuid='msg-uuid-{}'.format(MESSAGE_COUNT), - filename='{}-msg.gpg'.format(MESSAGE_COUNT), + uuid="msg-uuid-{}".format(MESSAGE_COUNT), + filename="{}-msg.gpg".format(MESSAGE_COUNT), size=123, - download_url='http://wat.onion/abc', + download_url="http://wat.onion/abc", is_decrypted=True, is_downloaded=True, - content='content', + content="content", ) defaults.update(attrs) @@ -82,12 +83,12 @@ def Reply(**attrs): global REPLY_COUNT REPLY_COUNT += 1 defaults = dict( - uuid='reply-uuid-{}'.format(REPLY_COUNT), - filename='{}-reply.gpg'.format(REPLY_COUNT), + uuid="reply-uuid-{}".format(REPLY_COUNT), + filename="{}-reply.gpg".format(REPLY_COUNT), size=123, is_decrypted=True, is_downloaded=True, - content='content', + content="content", ) defaults.update(attrs) @@ -103,8 +104,8 @@ def DraftReply(**attrs): source_id=1, journalist_id=1, file_counter=1, - uuid='draft-reply-uuid-{}'.format(REPLY_COUNT), - content='content', + uuid="draft-reply-uuid-{}".format(REPLY_COUNT), + content="content", send_status_id=1, ) @@ -116,9 +117,7 @@ def DraftReply(**attrs): def ReplySendStatus(**attrs): global REPLY_SEND_STATUS_COUNT REPLY_SEND_STATUS_COUNT += 1 - defaults = dict( - name=db.ReplySendStatusCodes.PENDING.value, - ) + defaults = dict(name=db.ReplySendStatusCodes.PENDING.value,) defaults.update(attrs) @@ -129,10 +128,10 @@ def File(**attrs): global FILE_COUNT FILE_COUNT += 1 defaults = dict( - uuid='file-uuid-{}'.format(FILE_COUNT), - filename='{}-doc.gz.gpg'.format(FILE_COUNT), + uuid="file-uuid-{}".format(FILE_COUNT), + filename="{}-doc.gz.gpg".format(FILE_COUNT), size=123, - download_url='http://wat.onion/abc', + download_url="http://wat.onion/abc", is_decrypted=True, is_downloaded=True, ) @@ -143,9 +142,10 @@ def File(**attrs): def dummy_job_factory(mocker, return_value, **kwargs): - ''' + """ Factory that creates dummy `ApiJob`s to DRY up test code. - ''' + """ + class DummyApiJob(ApiJob): success_signal = mocker.MagicMock() failure_signal = mocker.MagicMock() @@ -169,27 +169,24 @@ def call_api(self, api_client, session): def RemoteSource(**attrs): - with open(os.path.join(os.path.dirname(__file__), 'files', 'test-key.gpg.pub.asc')) as f: + with open(os.path.join(os.path.dirname(__file__), "files", "test-key.gpg.pub.asc")) as f: pub_key = f.read() defaults = dict( - add_star_url='foo', + add_star_url="foo", interaction_count=0, is_flagged=False, is_starred=True, - journalist_designation='testy-mctestface', - key={ - 'public': pub_key, - 'fingerprint': 'B2FF7FB28EED8CABEBC5FB6C6179D97BCFA52E5F' - }, + journalist_designation="testy-mctestface", + key={"public": pub_key, "fingerprint": "B2FF7FB28EED8CABEBC5FB6C6179D97BCFA52E5F"}, last_updated=datetime.now().isoformat(), number_of_documents=0, number_of_messages=0, - remove_star_url='baz', - replies_url='qux', - submissions_url='wibble', - url='url', - uuid=str(uuid.uuid4()) + remove_star_url="baz", + replies_url="qux", + submissions_url="wibble", + url="url", + uuid=str(uuid.uuid4()), ) defaults.update(attrs) diff --git a/tests/functional/test_download_file.py b/tests/functional/test_download_file.py index e02b5bbee..8f12da5b0 100644 --- a/tests/functional/test_download_file.py +++ b/tests/functional/test_download_file.py @@ -7,10 +7,9 @@ import pytest from flaky import flaky from PyQt5.QtCore import Qt -from securedrop_client.gui.widgets import FileWidget -from tests.conftest import (TIME_APP_START, TIME_FILE_DOWNLOAD, - TIME_RENDER_SOURCE_LIST, TIME_SYNC) +from securedrop_client.gui.widgets import FileWidget +from tests.conftest import TIME_APP_START, TIME_FILE_DOWNLOAD, TIME_RENDER_SOURCE_LIST, TIME_SYNC @flaky diff --git a/tests/functional/test_export_dialog.py b/tests/functional/test_export_dialog.py index f6f392d37..881a7a4b4 100644 --- a/tests/functional/test_export_dialog.py +++ b/tests/functional/test_export_dialog.py @@ -7,10 +7,15 @@ import pytest from flaky import flaky from PyQt5.QtCore import Qt -from securedrop_client.gui.widgets import FileWidget -from tests.conftest import (TIME_APP_START, TIME_CLICK_ACTION, TIME_FILE_DOWNLOAD, - TIME_RENDER_SOURCE_LIST, TIME_SYNC) +from securedrop_client.gui.widgets import FileWidget +from tests.conftest import ( + TIME_APP_START, + TIME_CLICK_ACTION, + TIME_FILE_DOWNLOAD, + TIME_RENDER_SOURCE_LIST, + TIME_SYNC, +) @flaky diff --git a/tests/functional/test_login.py b/tests/functional/test_login.py index 0d196b5dd..f77b81753 100644 --- a/tests/functional/test_login.py +++ b/tests/functional/test_login.py @@ -10,8 +10,7 @@ from securedrop_client.gui.main import Window from securedrop_client.gui.widgets import LoginDialog - -from tests.conftest import USERNAME, PASSWORD, TIME_RENDER_CONV_VIEW +from tests.conftest import PASSWORD, TIME_RENDER_CONV_VIEW, USERNAME def test_login_ensure_errors_displayed(qtbot, mocker): diff --git a/tests/functional/test_login_from_offline.py b/tests/functional/test_login_from_offline.py index 1e096ce64..a578e4652 100644 --- a/tests/functional/test_login_from_offline.py +++ b/tests/functional/test_login_from_offline.py @@ -5,8 +5,7 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_RENDER_CONV_VIEW, - PASSWORD, USERNAME) +from tests.conftest import PASSWORD, TIME_APP_START, TIME_RENDER_CONV_VIEW, USERNAME @flaky diff --git a/tests/functional/test_offline_delete_source.py b/tests/functional/test_offline_delete_source.py index e67a125d6..ae159b942 100644 --- a/tests/functional/test_offline_delete_source.py +++ b/tests/functional/test_offline_delete_source.py @@ -8,8 +8,12 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_CLICK_ACTION, - TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST) +from tests.conftest import ( + TIME_APP_START, + TIME_CLICK_ACTION, + TIME_RENDER_CONV_VIEW, + TIME_RENDER_SOURCE_LIST, +) @flaky @@ -50,6 +54,6 @@ def check_login_button(): def check_for_error(): # Confirm the user interface is showing a sign-in error. msg = gui.top_pane.error_status_bar.status_bar.currentMessage() - assert msg == 'You must sign in to perform this action.' + assert msg == "You must sign in to perform this action." qtbot.waitUntil(check_for_error, timeout=TIME_CLICK_ACTION) diff --git a/tests/functional/test_offline_read_conversations.py b/tests/functional/test_offline_read_conversations.py index 5cc931e2b..857d7e734 100644 --- a/tests/functional/test_offline_read_conversations.py +++ b/tests/functional/test_offline_read_conversations.py @@ -8,8 +8,12 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_CLICK_ACTION, - TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST) +from tests.conftest import ( + TIME_APP_START, + TIME_CLICK_ACTION, + TIME_RENDER_CONV_VIEW, + TIME_RENDER_SOURCE_LIST, +) @flaky diff --git a/tests/functional/test_offline_send_reply.py b/tests/functional/test_offline_send_reply.py index 772b458ef..93642b338 100644 --- a/tests/functional/test_offline_send_reply.py +++ b/tests/functional/test_offline_send_reply.py @@ -8,8 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_RENDER_CONV_VIEW, - TIME_RENDER_SOURCE_LIST) +from tests.conftest import TIME_APP_START, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @flaky diff --git a/tests/functional/test_offline_star_source.py b/tests/functional/test_offline_star_source.py index b2404d8c0..8f5e78223 100644 --- a/tests/functional/test_offline_star_source.py +++ b/tests/functional/test_offline_star_source.py @@ -8,8 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_RENDER_CONV_VIEW, - TIME_RENDER_SOURCE_LIST) +from tests.conftest import TIME_APP_START, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @flaky @@ -47,6 +46,6 @@ def check_login_button(): def check_for_error(): # Confirm the user interface is showing a sign-in error. msg = gui.top_pane.error_status_bar.status_bar.currentMessage() - assert msg == 'You must sign in to perform this action.' + assert msg == "You must sign in to perform this action." qtbot.waitUntil(check_for_error, timeout=TIME_RENDER_CONV_VIEW) diff --git a/tests/functional/test_receive_message.py b/tests/functional/test_receive_message.py index c5ab2c28f..841042af1 100644 --- a/tests/functional/test_receive_message.py +++ b/tests/functional/test_receive_message.py @@ -7,10 +7,9 @@ import pytest from flaky import flaky from PyQt5.QtCore import Qt -from securedrop_client.gui.widgets import FileWidget -from tests.conftest import (TIME_APP_START, TIME_RENDER_SOURCE_LIST, - TIME_SYNC) +from securedrop_client.gui.widgets import FileWidget +from tests.conftest import TIME_APP_START, TIME_RENDER_SOURCE_LIST, TIME_SYNC @flaky diff --git a/tests/functional/test_send_reply.py b/tests/functional/test_send_reply.py index 6de7f0026..ee29e8eed 100644 --- a/tests/functional/test_send_reply.py +++ b/tests/functional/test_send_reply.py @@ -8,7 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_CLICK_ACTION, TIME_RENDER_SOURCE_LIST) +from tests.conftest import TIME_APP_START, TIME_CLICK_ACTION, TIME_RENDER_SOURCE_LIST @flaky diff --git a/tests/functional/test_star_source.py b/tests/functional/test_star_source.py index 2b545df9a..9168eabb3 100644 --- a/tests/functional/test_star_source.py +++ b/tests/functional/test_star_source.py @@ -8,8 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_CLICK_ACTION, - TIME_RENDER_SOURCE_LIST) +from tests.conftest import TIME_APP_START, TIME_CLICK_ACTION, TIME_RENDER_SOURCE_LIST @flaky diff --git a/tests/functional/test_unstar_source.py b/tests/functional/test_unstar_source.py index fab0ace31..240ebd5c4 100644 --- a/tests/functional/test_unstar_source.py +++ b/tests/functional/test_unstar_source.py @@ -8,8 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt -from tests.conftest import (TIME_APP_START, TIME_CLICK_ACTION, - TIME_RENDER_SOURCE_LIST) +from tests.conftest import TIME_APP_START, TIME_CLICK_ACTION, TIME_RENDER_SOURCE_LIST @flaky diff --git a/tests/functional/test_user_icon_click.py b/tests/functional/test_user_icon_click.py index a90240799..b08d6b0b3 100644 --- a/tests/functional/test_user_icon_click.py +++ b/tests/functional/test_user_icon_click.py @@ -4,8 +4,8 @@ https://github.com/freedomofpress/securedrop-client/wiki/Test-plan#basic-client-testing """ -import pytest import pyautogui +import pytest from tests.conftest import TIME_APP_START, TIME_CLICK_ACTION diff --git a/tests/gui/test_init.py b/tests/gui/test_init.py index 13614f530..7e6c88dc6 100644 --- a/tests/gui/test_init.py +++ b/tests/gui/test_init.py @@ -4,7 +4,7 @@ from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QApplication -from securedrop_client.gui import SecureQLabel, SvgPushButton, SvgLabel, SvgToggleButton +from securedrop_client.gui import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton app = QApplication([]) @@ -16,14 +16,14 @@ def test_SvgToggleButton_init(mocker): """ svg_size = QSize(1, 1) icon = mocker.MagicMock() - load_icon_fn = mocker.patch('securedrop_client.gui.load_icon', return_value=icon) - setIcon_fn = mocker.patch('securedrop_client.gui.SvgToggleButton.setIcon') - setIconSize_fn = mocker.patch('securedrop_client.gui.SvgToggleButton.setIconSize') + load_icon_fn = mocker.patch("securedrop_client.gui.load_icon", return_value=icon) + setIcon_fn = mocker.patch("securedrop_client.gui.SvgToggleButton.setIcon") + setIconSize_fn = mocker.patch("securedrop_client.gui.SvgToggleButton.setIconSize") - stb = SvgToggleButton(on='mock_on', off='mock_off', svg_size=svg_size) + stb = SvgToggleButton(on="mock_on", off="mock_off", svg_size=svg_size) assert stb.isCheckable() is True - load_icon_fn.assert_called_once_with(normal='mock_on', normal_off='mock_off') + load_icon_fn.assert_called_once_with(normal="mock_on", normal_off="mock_off") setIcon_fn.assert_called_once_with(icon) setIconSize_fn.assert_called_once_with(svg_size) @@ -32,7 +32,7 @@ def test_SvgToggleButton_toggle(mocker): """ Make sure we're not calling this a toggle button for no reason. """ - stb = SvgToggleButton(on='mock_on', off='mock_off') + stb = SvgToggleButton(on="mock_on", off="mock_off") stb.toggle() assert stb.isChecked() is True stb.toggle() @@ -45,14 +45,14 @@ def test_SvgToggleButton_set_icon(mocker): """ Ensure set_icon loads and sets the icon. """ - setIcon_fn = mocker.patch('securedrop_client.gui.SvgToggleButton.setIcon') + setIcon_fn = mocker.patch("securedrop_client.gui.SvgToggleButton.setIcon") icon = mocker.MagicMock() - load_icon_fn = mocker.patch('securedrop_client.gui.load_icon', return_value=icon) - stb = SvgToggleButton(on='mock_on', off='mock_off') + load_icon_fn = mocker.patch("securedrop_client.gui.load_icon", return_value=icon) + stb = SvgToggleButton(on="mock_on", off="mock_off") - stb.set_icon(on='mock_on', off='mock_off') + stb.set_icon(on="mock_on", off="mock_off") - load_icon_fn.assert_called_with(normal='mock_on', normal_off='mock_off') + load_icon_fn.assert_called_with(normal="mock_on", normal_off="mock_off") setIcon_fn.assert_called_with(icon) assert stb.icon == icon @@ -63,16 +63,18 @@ def test_SvgPushButton_init(mocker): """ svg_size = QSize(1, 1) icon = mocker.MagicMock() - load_icon_fn = mocker.patch('securedrop_client.gui.load_icon', return_value=icon) - setIcon_fn = mocker.patch('securedrop_client.gui.SvgPushButton.setIcon') - setIconSize_fn = mocker.patch('securedrop_client.gui.SvgPushButton.setIconSize') + load_icon_fn = mocker.patch("securedrop_client.gui.load_icon", return_value=icon) + setIcon_fn = mocker.patch("securedrop_client.gui.SvgPushButton.setIcon") + setIconSize_fn = mocker.patch("securedrop_client.gui.SvgPushButton.setIconSize") spb = SvgPushButton( - normal='mock1', disabled='mock2', active='mock3', selected='mock4', svg_size=svg_size) + normal="mock1", disabled="mock2", active="mock3", selected="mock4", svg_size=svg_size + ) assert spb.isCheckable() is False load_icon_fn.assert_called_once_with( - normal='mock1', disabled='mock2', active='mock3', selected='mock4', disabled_off='mock2') + normal="mock1", disabled="mock2", active="mock3", selected="mock4", disabled_off="mock2" + ) setIcon_fn.assert_called_once_with(icon) setIconSize_fn.assert_called_once_with(svg_size) @@ -83,12 +85,12 @@ def test_SvgLabel_init(mocker): """ svg_size = QSize(1, 1) svg = mocker.MagicMock() - load_svg_fn = mocker.patch('securedrop_client.gui.load_svg', return_value=svg) - mocker.patch('securedrop_client.gui.QHBoxLayout.addWidget') + load_svg_fn = mocker.patch("securedrop_client.gui.load_svg", return_value=svg) + mocker.patch("securedrop_client.gui.QHBoxLayout.addWidget") - sl = SvgLabel(filename='mock', svg_size=svg_size) + sl = SvgLabel(filename="mock", svg_size=svg_size) - load_svg_fn.assert_called_once_with('mock') + load_svg_fn.assert_called_once_with("mock") sl.svg.setFixedSize.assert_called_once_with(svg_size) assert sl.svg == svg @@ -98,15 +100,15 @@ def test_SvgLabel_update(mocker): Ensure SvgLabel calls the expected methods correctly to set the icon and size. """ svg = mocker.MagicMock() - load_svg_fn = mocker.patch('securedrop_client.gui.load_svg', return_value=svg) - mocker.patch('securedrop_client.gui.QHBoxLayout.addWidget') - sl = SvgLabel(filename='mock', svg_size=QSize(1, 1)) + load_svg_fn = mocker.patch("securedrop_client.gui.load_svg", return_value=svg) + mocker.patch("securedrop_client.gui.QHBoxLayout.addWidget") + sl = SvgLabel(filename="mock", svg_size=QSize(1, 1)) - sl.update_image(filename='mock_two', svg_size=QSize(2, 2)) + sl.update_image(filename="mock_two", svg_size=QSize(2, 2)) assert sl.svg == svg - assert load_svg_fn.call_args_list[0][0][0] == 'mock' - assert load_svg_fn.call_args_list[1][0][0] == 'mock_two' + assert load_svg_fn.call_args_list[0][0][0] == "mock" + assert load_svg_fn.call_args_list[1][0][0] == "mock_two" assert sl.svg.setFixedSize.call_args_list[0][0][0] == QSize(1, 1) assert sl.svg.setFixedSize.call_args_list[1][0][0] == QSize(2, 2) @@ -118,18 +120,20 @@ def test_SecureQLabel_init(): def test_SecureQLabel_init_wordwrap(mocker): - ''' + """ Regression test to make sure we don't remove newlines. - ''' - long_string = ('1234567890123456789012345678901234567890123456789012345678901234567890\n' - '12345678901') + """ + long_string = ( + "1234567890123456789012345678901234567890123456789012345678901234567890\n" "12345678901" + ) sl = SecureQLabel(long_string, wordwrap=False) assert sl.text() == long_string def test_SecureQLabel_init_no_wordwrap(mocker): - long_string = ('1234567890123456789012345678901234567890123456789012345678901234567890\n' - '12345678901') + long_string = ( + "1234567890123456789012345678901234567890123456789012345678901234567890\n" "12345678901" + ) sl = SecureQLabel(long_string, wordwrap=False) assert sl.text() == long_string @@ -148,38 +152,38 @@ def test_SecureQLabel_setText(mocker): def test_SecureQLabel_get_elided_text(mocker): # 70 character string - long_string = '1234567890123456789012345678901234567890123456789012345678901234567890' + long_string = "1234567890123456789012345678901234567890123456789012345678901234567890" sl = SecureQLabel(long_string, wordwrap=False, max_length=100) elided_text = sl.get_elided_text(long_string) assert sl.text() == elided_text - assert '...' in elided_text + assert "..." in elided_text def test_SecureQLabel_get_elided_text_short_string(mocker): # 70 character string - long_string = '123456789' + long_string = "123456789" sl = SecureQLabel(long_string, wordwrap=False, max_length=100) elided_text = sl.get_elided_text(long_string) assert sl.text() == elided_text - assert elided_text == '123456789' + assert elided_text == "123456789" def test_SecureQLabel_get_elided_text_only_returns_oneline(mocker): # 70 character string - string_with_newline = ('this is a string\n with a newline') + string_with_newline = "this is a string\n with a newline" sl = SecureQLabel(string_with_newline, wordwrap=False, max_length=100) elided_text = sl.get_elided_text(string_with_newline) assert sl.text() == elided_text - assert elided_text == 'this is a string' + assert elided_text == "this is a string" def test_SecureQLabel_get_elided_text_only_returns_oneline_elided(mocker): # 70 character string - string_with_newline = ('this is a string\n with a newline') + string_with_newline = "this is a string\n with a newline" sl = SecureQLabel(string_with_newline, wordwrap=False, max_length=38) elided_text = sl.get_elided_text(string_with_newline) assert sl.text() == elided_text - assert '...' in elided_text + assert "..." in elided_text def test_SecureQLabel_quotes_not_escaped_for_readability(): @@ -188,6 +192,6 @@ def test_SecureQLabel_quotes_not_escaped_for_readability(): def test_SecureQLabel_trims_leading_and_trailing_whitespace(): - string_with_whitespace = ('\n \n this is a string with leading and trailing whitespace \n') + string_with_whitespace = "\n \n this is a string with leading and trailing whitespace \n" sl = SecureQLabel(string_with_whitespace) assert sl.text() == "this is a string with leading and trailing whitespace" diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index 2e59b867f..fb6563b4e 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -6,7 +6,6 @@ from securedrop_client.gui.main import Window from securedrop_client.resources import load_icon - app = QApplication([]) @@ -14,16 +13,16 @@ def test_init(mocker): """ Ensure the Window instance is setup in the expected manner. """ - mock_li = mocker.MagicMock(return_value=load_icon('icon.png')) + mock_li = mocker.MagicMock(return_value=load_icon("icon.png")) mock_lo = mocker.MagicMock(return_value=QHBoxLayout()) mock_lo().addWidget = mocker.MagicMock() - mocker.patch('securedrop_client.gui.main.load_icon', mock_li) - mock_lp = mocker.patch('securedrop_client.gui.main.LeftPane') - mock_mv = mocker.patch('securedrop_client.gui.main.MainView') - mocker.patch('securedrop_client.gui.main.QHBoxLayout', mock_lo) - mocker.patch('securedrop_client.gui.main.QMainWindow') - mocker.patch('securedrop_client.gui.main.Window.setStyleSheet') - load_css = mocker.patch('securedrop_client.gui.main.load_css') + mocker.patch("securedrop_client.gui.main.load_icon", mock_li) + mock_lp = mocker.patch("securedrop_client.gui.main.LeftPane") + mock_mv = mocker.patch("securedrop_client.gui.main.MainView") + mocker.patch("securedrop_client.gui.main.QHBoxLayout", mock_lo) + mocker.patch("securedrop_client.gui.main.QMainWindow") + mocker.patch("securedrop_client.gui.main.Window.setStyleSheet") + load_css = mocker.patch("securedrop_client.gui.main.load_css") w = Window() @@ -31,7 +30,7 @@ def test_init(mocker): mock_lp.assert_called_once_with() mock_mv.assert_called_once_with(w.main_pane) assert mock_lo().addWidget.call_count == 2 - load_css.assert_called_once_with('sdclient.css') + load_css.assert_called_once_with("sdclient.css") def test_setup(mocker): @@ -95,7 +94,7 @@ def test_autosize_window(mocker): mock_sg = mocker.MagicMock() mock_sg.screenGeometry.return_value = mock_screen mock_qdw = mocker.MagicMock(return_value=mock_sg) - mocker.patch('securedrop_client.gui.main.QDesktopWidget', mock_qdw) + mocker.patch("securedrop_client.gui.main.QDesktopWidget", mock_qdw) w.autosize_window() w.resize.assert_called_once_with(1024, 768) @@ -106,7 +105,7 @@ def test_show_login(mocker): """ w = Window() w.controller = mocker.MagicMock() - mock_ld = mocker.patch('securedrop_client.gui.main.LoginDialog') + mock_ld = mocker.patch("securedrop_client.gui.main.LoginDialog") w.show_login() @@ -121,14 +120,14 @@ def test_show_login_with_error_message(mocker): """ w = Window() w.controller = mocker.MagicMock() - mock_ld = mocker.patch('securedrop_client.gui.main.LoginDialog') + mock_ld = mocker.patch("securedrop_client.gui.main.LoginDialog") - w.show_login('this-is-an-error-message-to-show-on-login-window') + w.show_login("this-is-an-error-message-to-show-on-login-window") mock_ld.assert_called_once_with(w) w.login_dialog.reset.assert_called_once_with() w.login_dialog.show.assert_called_once_with() - w.login_dialog.error.assert_called_once_with('this-is-an-error-message-to-show-on-login-window') + w.login_dialog.error.assert_called_once_with("this-is-an-error-message-to-show-on-login-window") def test_show_login_error(mocker): @@ -140,9 +139,9 @@ def test_show_login_error(mocker): w.setup(mocker.MagicMock()) w.login_dialog = mocker.MagicMock() - w.show_login_error('boom') + w.show_login_error("boom") - w.login_dialog.error.assert_called_once_with('boom') + w.login_dialog.error.assert_called_once_with("boom") def test_hide_login(mocker): @@ -191,8 +190,8 @@ def test_update_error_status_default(mocker): """ w = Window() w.top_pane = mocker.MagicMock() - w.update_error_status(message='test error message') - w.top_pane.update_error_status.assert_called_once_with('test error message', 10000) + w.update_error_status(message="test error message") + w.top_pane.update_error_status.assert_called_once_with("test error message", 10000) def test_update_error_status(mocker): @@ -202,8 +201,8 @@ def test_update_error_status(mocker): """ w = Window() w.top_pane = mocker.MagicMock() - w.update_error_status(message='test error message', duration=123) - w.top_pane.update_error_status.assert_called_once_with('test error message', 123) + w.update_error_status(message="test error message", duration=123) + w.top_pane.update_error_status.assert_called_once_with("test error message", 123) def test_update_activity_status_default(mocker): @@ -213,8 +212,8 @@ def test_update_activity_status_default(mocker): """ w = Window() w.top_pane = mocker.MagicMock() - w.update_activity_status(message='test message') - w.top_pane.update_activity_status.assert_called_once_with('test message', 0) + w.update_activity_status(message="test message") + w.top_pane.update_activity_status.assert_called_once_with("test message", 0) def test_update_activity_status(mocker): @@ -224,8 +223,8 @@ def test_update_activity_status(mocker): """ w = Window() w.top_pane = mocker.MagicMock() - w.update_activity_status(message='test message', duration=123) - w.top_pane.update_activity_status.assert_called_once_with('test message', 123) + w.update_activity_status(message="test message", duration=123) + w.top_pane.update_activity_status.assert_called_once_with("test message", 123) def test_clear_error_status(mocker): @@ -249,7 +248,8 @@ def test_show_last_sync(mocker): updated_on = mocker.MagicMock() w.show_last_sync(updated_on) w.update_activity_status.assert_called_once_with( - 'Last Refresh: {}'.format(updated_on.humanize())) + "Last Refresh: {}".format(updated_on.humanize()) + ) def test_show_last_sync_no_sync(mocker): @@ -259,7 +259,7 @@ def test_show_last_sync_no_sync(mocker): w = Window() w.update_activity_status = mocker.MagicMock() w.show_last_sync(None) - w.update_activity_status.assert_called_once_with('Last Refresh: never') + w.update_activity_status.assert_called_once_with("Last Refresh: never") def test_set_logged_in_as(mocker): @@ -269,9 +269,9 @@ def test_set_logged_in_as(mocker): w = Window() w.left_pane = mocker.MagicMock() - w.set_logged_in_as('test') + w.set_logged_in_as("test") - w.left_pane.set_logged_in_as.assert_called_once_with('test') + w.left_pane.set_logged_in_as.assert_called_once_with("test") def test_logout(mocker): @@ -293,8 +293,7 @@ def test_clear_clipboard(mocker): Ensure we are clearing the system-level clipboard in the expected manner. """ mock_clipboard = mocker.MagicMock() - mocker.patch('securedrop_client.gui.main.QApplication.clipboard', - return_value=mock_clipboard) + mocker.patch("securedrop_client.gui.main.QApplication.clipboard", return_value=mock_clipboard) w = Window() w.clear_clipboard() mock_clipboard.clear.assert_called_once_with() diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 5160c92d5..12bdc9cac 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1,32 +1,61 @@ """ Make sure the UI widgets are configured correctly and work as expected. """ -import pytest -import arrow from datetime import datetime from unittest.mock import Mock, patch -from PyQt5.QtCore import Qt, QEvent -from PyQt5.QtGui import QFocusEvent, QKeyEvent, QMovie -from PyQt5.QtTest import QTest -from PyQt5.QtWidgets import QWidget, QApplication, QVBoxLayout, QMessageBox, QMainWindow, \ - QLineEdit +import arrow +import pytest import sqlalchemy import sqlalchemy.orm.exc +from PyQt5.QtCore import QEvent, Qt +from PyQt5.QtGui import QFocusEvent, QKeyEvent, QMovie +from PyQt5.QtTest import QTest +from PyQt5.QtWidgets import QApplication, QLineEdit, QMainWindow, QMessageBox, QVBoxLayout, QWidget from sqlalchemy.orm import attributes, scoped_session, sessionmaker from securedrop_client import db, logic, storage from securedrop_client.export import ExportError, ExportStatus -from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, SecureQLabel, \ - LoginDialog, SpeechBubble, MessageWidget, ReplyWidget, FileWidget, ConversationView, \ - DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, SyncIcon, \ - ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \ - ReplyBoxWidget, ReplyTextEdit, SourceConversationWrapper, StarToggleButton, LoginOfflineLink, \ - LoginErrorBar, EmptyConversationView, ModalDialog, ExportDialog, PrintDialog, \ - PasswordEdit, SourceProfileShortWidget, SourceListWidgetItem, UserIconLabel +from securedrop_client.gui.widgets import ( + ActivityStatusBar, + ConversationView, + DeleteSourceAction, + DeleteSourceMessageBox, + EmptyConversationView, + ErrorStatusBar, + ExportDialog, + FileWidget, + LeftPane, + LoginButton, + LoginDialog, + LoginErrorBar, + LoginOfflineLink, + MainView, + MessageWidget, + ModalDialog, + PasswordEdit, + PrintDialog, + ReplyBoxWidget, + ReplyTextEdit, + ReplyWidget, + SecureQLabel, + SourceConversationWrapper, + SourceList, + SourceListWidgetItem, + SourceMenu, + SourceProfileShortWidget, + SourceWidget, + SpeechBubble, + StarToggleButton, + SyncIcon, + TopPane, + UserButton, + UserIconLabel, + UserMenu, + UserProfile, +) from tests import factory - app = QApplication([]) @@ -36,8 +65,8 @@ def test_TopPane_init(mocker): """ tp = TopPane() file_path = tp.sync_icon.sync_animation.fileName() - filename = file_path[file_path.rfind('/') + 1:] - assert filename == 'sync_disabled.gif' + filename = file_path[file_path.rfind("/") + 1 :] + assert filename == "sync_disabled.gif" def test_TopPane_setup(mocker): @@ -84,9 +113,9 @@ def test_TopPane_update_activity_status(mocker): tp = TopPane() tp.activity_status_bar = mocker.MagicMock() - tp.update_activity_status(message='test message', duration=5) + tp.update_activity_status(message="test message", duration=5) - tp.activity_status_bar.update_message.assert_called_once_with('test message', 5) + tp.activity_status_bar.update_message.assert_called_once_with("test message", 5) def test_TopPane_update_error_status(mocker): @@ -96,9 +125,9 @@ def test_TopPane_update_error_status(mocker): tp = TopPane() tp.error_status_bar = mocker.MagicMock() - tp.update_error_status(message='test message', duration=5) + tp.update_error_status(message="test message", duration=5) - tp.error_status_bar.update_message.assert_called_once_with('test message', 5) + tp.error_status_bar.update_message.assert_called_once_with("test message", 5) def test_TopPane_clear_error_status(mocker): @@ -165,14 +194,14 @@ def test_LeftPane_set_logged_out(mocker): def test_SyncIcon_init(mocker): sync_icon = SyncIcon() file_path = sync_icon.sync_animation.fileName() - filename = file_path[file_path.rfind('/') + 1:] - assert filename == 'sync_disabled.gif' + filename = file_path[file_path.rfind("/") + 1 :] + assert filename == "sync_disabled.gif" def test_SyncIcon_init_starts_animiation(mocker): movie = QMovie() movie.start = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.load_movie', return_value=movie) + mocker.patch("securedrop_client.gui.widgets.load_movie", return_value=movie) sync_icon = SyncIcon() @@ -198,14 +227,14 @@ def test_SyncIcon_enable(mocker): sync_icon.enable() file_path = sync_icon.sync_animation.fileName() - filename = file_path[file_path.rfind('/') + 1:] - assert filename == 'sync.gif' + filename = file_path[file_path.rfind("/") + 1 :] + assert filename == "sync.gif" def test_SyncIcon_enable_starts_animiation(mocker): movie = QMovie() movie.start = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.load_movie', return_value=movie) + mocker.patch("securedrop_client.gui.widgets.load_movie", return_value=movie) sync_icon = SyncIcon() sync_icon.enable() @@ -219,14 +248,14 @@ def test_SyncIcon_disable(mocker): sync_icon.disable() file_path = sync_icon.sync_animation.fileName() - filename = file_path[file_path.rfind('/') + 1:] - assert filename == 'sync_disabled.gif' + filename = file_path[file_path.rfind("/") + 1 :] + assert filename == "sync_disabled.gif" def test_SyncIcon_disable_starts_animiation(mocker): movie = QMovie() movie.start = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.load_movie', return_value=movie) + mocker.patch("securedrop_client.gui.widgets.load_movie", return_value=movie) sync_icon = SyncIcon() sync_icon.disable() @@ -235,44 +264,44 @@ def test_SyncIcon_disable_starts_animiation(mocker): def test_SyncIcon__on_sync_syncing(mocker): - ''' + """ Sync icon becomes active when it receives the `syncing` signal. - ''' + """ sync_icon = SyncIcon() - sync_icon._on_sync('syncing') + sync_icon._on_sync("syncing") file_path = sync_icon.sync_animation.fileName() - filename = file_path[file_path.rfind('/') + 1:] - assert filename == 'sync_active.gif' + filename = file_path[file_path.rfind("/") + 1 :] + assert filename == "sync_active.gif" def test_SyncIcon__on_sync_synced(mocker): - ''' + """ Sync icon becomes "inactive" when it receives the `synced` signal. - ''' + """ sync_icon = SyncIcon() - sync_icon._on_sync('synced') + sync_icon._on_sync("synced") file_path = sync_icon.sync_animation.fileName() - filename = file_path[file_path.rfind('/') + 1:] - assert filename == 'sync.gif' + filename = file_path[file_path.rfind("/") + 1 :] + assert filename == "sync.gif" def test_SyncIcon___on_sync_with_data_not_equal_to_syncing(mocker): - ''' + """ Sync does not because active when the sync signal's data is something other than 'syncing' - ''' + """ movie = QMovie() movie.start = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.load_movie', return_value=movie) + mocker.patch("securedrop_client.gui.widgets.load_movie", return_value=movie) sync_icon = SyncIcon() # assert that start call count has only been called once sync_icon.sync_animation.start.assert_called_once_with() - sync_icon._on_sync('something other than syncing') + sync_icon._on_sync("something other than syncing") # assert that _on_sync doesn't increase start call count from one sync_icon.sync_animation.start.assert_called_once_with() @@ -299,9 +328,9 @@ def test_ErrorStatusBar_update_message(mocker): esb.status_bar = mocker.MagicMock() esb.status_timer = mocker.MagicMock() - esb.update_message(message='test message', duration=123) + esb.update_message(message="test message", duration=123) - esb.status_bar.showMessage.assert_called_once_with('test message', 123) + esb.status_bar.showMessage.assert_called_once_with("test message", 123) esb.status_timer.start.assert_called_once_with(123) @@ -342,8 +371,8 @@ def test_ActivityStatusBar_update_message(mocker): Calling update_message updates the message of the QStatusBar. """ asb = ActivityStatusBar() - asb.update_message(message='test message', duration=123) - assert asb.currentMessage() == 'test message' + asb.update_message(message="test message", duration=123) + assert asb.currentMessage() == "test message" def test_UserProfile_setup(mocker): @@ -363,12 +392,12 @@ def test_UserProfile_set_user(mocker): up = UserProfile() up.user_icon = mocker.MagicMock() up.user_button = mocker.MagicMock() - user = factory.User(firstname='firstname_mock', lastname='lastname_mock') + user = factory.User(firstname="firstname_mock", lastname="lastname_mock") up.set_user(user) - up.user_icon.setText.assert_called_with('fl') - up.user_button.set_username.assert_called_with('firstname_mock lastname_mock') + up.user_icon.setText.assert_called_with("fl") + up.user_button.set_username.assert_called_with("firstname_mock lastname_mock") def test_UserProfile_show(mocker): @@ -416,17 +445,15 @@ def test_UserButton_setup(mocker): def test_UserButton_set_username(): ub = UserButton() - ub.set_username('test_username') - ub.text() == 'test_username' + ub.set_username("test_username") + ub.text() == "test_username" def test_UserButton_set_long_username(mocker): ub = UserButton() ub.setToolTip = mocker.MagicMock() - ub.set_username('test_username_that_is_very_very_long') - ub.setToolTip.assert_called_once_with( - 'test_username_that_is_very_very_long' - ) + ub.set_username("test_username_that_is_very_very_long") + ub.setToolTip.assert_called_once_with("test_username_that_is_very_very_long") def test_UserMenu_setup(mocker): @@ -458,7 +485,7 @@ def test_UserMenu_on_item_selected(mocker): def test_LoginButton_init(mocker): lb = LoginButton() - assert lb.text() == 'SIGN IN' + assert lb.text() == "SIGN IN" def test_LoginButton_setup(mocker): @@ -562,16 +589,16 @@ def test_MainView_delete_conversation_when_conv_wrapper_exists(mocker): """ Ensure SourceConversationWrapper is deleted if it exists. """ - source = factory.Source(uuid='123') + source = factory.Source(uuid="123") conversation_wrapper = SourceConversationWrapper(source, mocker.MagicMock()) conversation_wrapper.deleteLater = mocker.MagicMock() mock_source_conv_wrapper_widget = mocker.MagicMock() mock_source_conv_wrapper_widget.deleteLater = mocker.MagicMock() mv = MainView(None) mv.source_conversations = {} - mv.source_conversations['123'] = conversation_wrapper + mv.source_conversations["123"] = conversation_wrapper - mv.delete_conversation('123') + mv.delete_conversation("123") conversation_wrapper.deleteLater.assert_called_once_with() assert mv.source_conversations == {} @@ -582,7 +609,7 @@ def test_MainView_delete_conversation_when_conv_wrapper_does_not_exist(mocker): Ensure that delete_conversation throws no exception if the SourceConversationWrapper does not exist. """ - source_uuid = 'foo' + source_uuid = "foo" mv = MainView(None) mv.source_conversations = {} @@ -600,9 +627,9 @@ def test_MainView_on_source_changed(mocker): mv.source_list = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=factory.Source()) mv.controller = mocker.MagicMock(is_authenticated=True) - mocker.patch('securedrop_client.gui.widgets.source_exists', return_value=True) + mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True) scw = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.SourceConversationWrapper', return_value=scw) + mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw) mv.on_source_changed() @@ -634,20 +661,23 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session): mv.controller = mocker.MagicMock(is_authenticated=True) s = factory.Source() session.add(s) - f = factory.File(source=s, filename='0-mock-doc.gpg') + f = factory.File(source=s, filename="0-mock-doc.gpg") session.add(f) - m = factory.Message(source=s, filename='0-mock-msg.gpg') + m = factory.Message(source=s, filename="0-mock-msg.gpg") session.add(m) - r = factory.Reply(source=s, filename='0-mock-reply.gpg') + r = factory.Reply(source=s, filename="0-mock-reply.gpg") session.add(r) session.commit() mv.source_list.get_selected_source = mocker.MagicMock(return_value=s) add_message_fn = mocker.patch( - 'securedrop_client.gui.widgets.ConversationView.add_message', new=mocker.Mock()) + "securedrop_client.gui.widgets.ConversationView.add_message", new=mocker.Mock() + ) add_reply_fn = mocker.patch( - 'securedrop_client.gui.widgets.ConversationView.add_reply', new=mocker.Mock()) + "securedrop_client.gui.widgets.ConversationView.add_reply", new=mocker.Mock() + ) add_file_fn = mocker.patch( - 'securedrop_client.gui.widgets.ConversationView.add_file', new=mocker.Mock()) + "securedrop_client.gui.widgets.ConversationView.add_file", new=mocker.Mock() + ) mv.on_source_changed() @@ -673,8 +703,8 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke session.commit() source_conversation_init = mocker.patch( - 'securedrop_client.gui.widgets.SourceConversationWrapper.__init__', - return_value=None) + "securedrop_client.gui.widgets.SourceConversationWrapper.__init__", return_value=None + ) # We expect on the first call, SourceConversationWrapper.__init__ should be called. mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) @@ -781,10 +811,14 @@ def test_SourceList_update_adds_new_sources(mocker): mock_sw = mocker.MagicMock() mock_lwi = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.SourceWidget', mock_sw) - mocker.patch('securedrop_client.gui.widgets.SourceListWidgetItem', mock_lwi) - - sources = [mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), ] + mocker.patch("securedrop_client.gui.widgets.SourceWidget", mock_sw) + mocker.patch("securedrop_client.gui.widgets.SourceListWidgetItem", mock_lwi) + + sources = [ + mocker.MagicMock(), + mocker.MagicMock(), + mocker.MagicMock(), + ] sl.update(sources) assert mock_sw.call_count == len(sources) @@ -811,7 +845,11 @@ def test_SourceList_initial_update_adds_new_sources(mocker): sl.currentRow = mocker.MagicMock(return_value=0) sl.item = mocker.MagicMock() sl.item().isSelected.return_value = True - sources = [mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), ] + sources = [ + mocker.MagicMock(), + mocker.MagicMock(), + mocker.MagicMock(), + ] sl.initial_update(sources) sl.add_source.assert_called_once_with(sources) @@ -825,7 +863,7 @@ def test_SourceList_update_when_source_deleted(mocker, session, session_maker, h ongoing sync will handle the deletion of the source's widgets. """ mock_gui = mocker.MagicMock() - controller = logic.Controller('http://localhost', mock_gui, session_maker, homedir) + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir) # create the source in another session source = factory.Source() @@ -867,7 +905,11 @@ def test_SourceList_add_source_starts_timer(mocker, session_maker, homedir): to the source list via a single-shot QTimer. """ sl = SourceList() - sources = [mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), ] + sources = [ + mocker.MagicMock(), + mocker.MagicMock(), + mocker.MagicMock(), + ] mock_timer = mocker.MagicMock() with mocker.patch("securedrop_client.gui.widgets.QTimer", mock_timer): sl.add_source(sources) @@ -881,15 +923,15 @@ def uuid(self): def test_SourceList_initial_update_does_not_raise_exc_and_no_widget_created(mocker, qtbot): - ''' + """ This is a regression test to make sure we raise an exception when adding a new source **before** we add a SourceWidget to the SourceList and try to insert into the source_items map. - ''' + """ sl = SourceList() sl.controller = mocker.MagicMock() # Make sure SourceWidget constructor doesn't raise source_widget = SourceWidget(sl.controller, factory.Source()) - mocker.patch('securedrop_client.gui.widgets.SourceWidget', return_value=source_widget) + mocker.patch("securedrop_client.gui.widgets.SourceWidget", return_value=source_widget) source = DeletedSource() sl.initial_update([source]) @@ -948,7 +990,7 @@ def test_SourceList_update_with_pre_selected_source_maintains_selection(mocker): sl.update([factory.Source(), factory.Source()]) second_item = sl.itemAt(1, 0) sl.setCurrentItem(second_item) # select the second source - mocker.patch.object(second_item, 'isSelected', return_value=True) + mocker.patch.object(second_item, "isSelected", return_value=True) sl.update([factory.Source(), factory.Source()]) @@ -961,10 +1003,10 @@ def test_SourceList_update_removes_selected_item_results_in_no_current_selection """ sl = SourceList() sl.controller = mocker.MagicMock() - sl.update([factory.Source(uuid='new'), factory.Source(uuid='newer')]) + sl.update([factory.Source(uuid="new"), factory.Source(uuid="newer")]) sl.setCurrentItem(sl.itemAt(0, 0)) # select source with uuid='newer' - sl.update([factory.Source(uuid='new')]) # delete source with uuid='newer' + sl.update([factory.Source(uuid="new")]) # delete source with uuid='newer' assert not sl.currentItem() @@ -975,13 +1017,14 @@ def test_SourceList_update_removes_item_from_end_of_list(mocker): """ sl = SourceList() sl.controller = mocker.MagicMock() - sl.update([ - factory.Source(uuid='new'), factory.Source(uuid='newer'), factory.Source(uuid='newest')]) + sl.update( + [factory.Source(uuid="new"), factory.Source(uuid="newer"), factory.Source(uuid="newest")] + ) assert sl.count() == 3 - sl.update([factory.Source(uuid='newer'), factory.Source(uuid='newest')]) + sl.update([factory.Source(uuid="newer"), factory.Source(uuid="newest")]) assert sl.count() == 2 - assert sl.itemWidget(sl.item(0)).source.uuid == 'newest' - assert sl.itemWidget(sl.item(1)).source.uuid == 'newer' + assert sl.itemWidget(sl.item(0)).source.uuid == "newest" + assert sl.itemWidget(sl.item(1)).source.uuid == "newer" assert len(sl.source_items) == 2 @@ -991,13 +1034,14 @@ def test_SourceList_update_removes_item_from_middle_of_list(mocker): """ sl = SourceList() sl.controller = mocker.MagicMock() - sl.update([ - factory.Source(uuid='new'), factory.Source(uuid='newer'), factory.Source(uuid='newest')]) + sl.update( + [factory.Source(uuid="new"), factory.Source(uuid="newer"), factory.Source(uuid="newest")] + ) assert sl.count() == 3 - sl.update([factory.Source(uuid='new'), factory.Source(uuid='newest')]) + sl.update([factory.Source(uuid="new"), factory.Source(uuid="newest")]) assert sl.count() == 2 - assert sl.itemWidget(sl.item(0)).source.uuid == 'newest' - assert sl.itemWidget(sl.item(1)).source.uuid == 'new' + assert sl.itemWidget(sl.item(0)).source.uuid == "newest" + assert sl.itemWidget(sl.item(1)).source.uuid == "new" assert len(sl.source_items) == 2 @@ -1007,13 +1051,14 @@ def test_SourceList_update_removes_item_from_beginning_of_list(mocker): """ sl = SourceList() sl.controller = mocker.MagicMock() - sl.update([ - factory.Source(uuid='new'), factory.Source(uuid='newer'), factory.Source(uuid='newest')]) + sl.update( + [factory.Source(uuid="new"), factory.Source(uuid="newer"), factory.Source(uuid="newest")] + ) assert sl.count() == 3 - sl.update([factory.Source(uuid='new'), factory.Source(uuid='newer')]) + sl.update([factory.Source(uuid="new"), factory.Source(uuid="newer")]) assert sl.count() == 2 - assert sl.itemWidget(sl.item(0)).source.uuid == 'newer' - assert sl.itemWidget(sl.item(1)).source.uuid == 'new' + assert sl.itemWidget(sl.item(0)).source.uuid == "newer" + assert sl.itemWidget(sl.item(1)).source.uuid == "new" assert len(sl.source_items) == 2 @@ -1030,9 +1075,13 @@ def test_SourceList_add_source_closure_adds_sources(mocker): mock_sw = mocker.MagicMock() mock_lwi = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.SourceWidget', mock_sw) - mocker.patch('securedrop_client.gui.widgets.SourceListWidgetItem', mock_lwi) - sources = [mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), ] + mocker.patch("securedrop_client.gui.widgets.SourceWidget", mock_sw) + mocker.patch("securedrop_client.gui.widgets.SourceListWidgetItem", mock_lwi) + sources = [ + mocker.MagicMock(), + mocker.MagicMock(), + mocker.MagicMock(), + ] mock_timer = mocker.MagicMock() with mocker.patch("securedrop_client.gui.widgets.QTimer", mock_timer): sl.add_source(sources, 1) @@ -1065,8 +1114,8 @@ def test_SourceList_add_source_closure_exits_on_no_more_sources(mocker): mock_sw = mocker.MagicMock() mock_lwi = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.SourceWidget', mock_sw) - mocker.patch('securedrop_client.gui.widgets.SourceListWidgetItem', mock_lwi) + mocker.patch("securedrop_client.gui.widgets.SourceWidget", mock_sw) + mocker.patch("securedrop_client.gui.widgets.SourceListWidgetItem", mock_lwi) sources = [] mock_timer = mocker.MagicMock() with mocker.patch("securedrop_client.gui.widgets.QTimer", mock_timer): @@ -1092,7 +1141,7 @@ def test_SourceList_set_snippet(mocker): Handle the emitted event in the expected manner. """ sl = SourceList() - source_widget = SourceWidget(mocker.MagicMock(), factory.Source(uuid='mock_uuid')) + source_widget = SourceWidget(mocker.MagicMock(), factory.Source(uuid="mock_uuid")) source_widget.set_snippet = mocker.MagicMock() source_item = SourceListWidgetItem(sl) sl.setItemWidget(source_item, source_widget) @@ -1106,35 +1155,35 @@ def test_SourceList_set_snippet(mocker): def test_SourceList_get_source_widget(mocker): sl = SourceList() sl.controller = mocker.MagicMock() - sl.update([factory.Source(uuid='mock_uuid')]) + sl.update([factory.Source(uuid="mock_uuid")]) sl.source_items = {} - source_widget = sl.get_source_widget('mock_uuid') + source_widget = sl.get_source_widget("mock_uuid") - assert source_widget.source_uuid == 'mock_uuid' + assert source_widget.source_uuid == "mock_uuid" assert source_widget == sl.itemWidget(sl.item(0)) def test_SourceList_get_source_widget_does_not_exist(mocker): sl = SourceList() sl.controller = mocker.MagicMock() - mock_source = factory.Source(uuid='mock_uuid') + mock_source = factory.Source(uuid="mock_uuid") sl.update([mock_source]) sl.source_items = {} - source_widget = sl.get_source_widget('uuid_for_source_not_in_list') + source_widget = sl.get_source_widget("uuid_for_source_not_in_list") assert source_widget is None def test_SourceList_get_source_widget_if_one_exists_in_cache(mocker): sl = SourceList() - source_widget = SourceWidget(mocker.MagicMock(), factory.Source(uuid='mock_uuid')) + source_widget = SourceWidget(mocker.MagicMock(), factory.Source(uuid="mock_uuid")) source_item = SourceListWidgetItem(sl) sl.setItemWidget(source_item, source_widget) - sl.source_items['mock_uuid'] = source_item + sl.source_items["mock_uuid"] = source_item - assert sl.get_source_widget('mock_uuid') == source_widget + assert sl.get_source_widget("mock_uuid") == source_widget def test_SourceWidget_init(mocker): @@ -1143,7 +1192,7 @@ def test_SourceWidget_init(mocker): """ controller = mocker.MagicMock() mock_source = mocker.MagicMock() - mock_source.journalist_designation = 'foo bar baz' + mock_source.journalist_designation = "foo bar baz" sw = SourceWidget(controller, mock_source) assert sw.source == mock_source @@ -1155,16 +1204,16 @@ def test_SourceWidget_html_init(mocker): """ controller = mocker.MagicMock() mock_source = mocker.MagicMock() - mock_source.journalist_designation = 'foo bar baz' + mock_source.journalist_designation = "foo bar baz" sw = SourceWidget(controller, mock_source) sw.name = mocker.MagicMock() sw.summary_layout = mocker.MagicMock() - mocker.patch('securedrop_client.gui.SvgLabel') + mocker.patch("securedrop_client.gui.SvgLabel") sw.update() - sw.name.setText.assert_called_once_with('foo bar baz') + sw.name.setText.assert_called_once_with("foo bar baz") def test_SourceWidget_update_attachment_icon(mocker): @@ -1205,7 +1254,7 @@ def test_SourceWidget_set_snippet_draft_only(mocker, session_maker, session, hom Snippets/previews do not include draft messages. """ mock_gui = mocker.MagicMock() - controller = logic.Controller('http://localhost', mock_gui, session_maker, homedir) + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir) source = factory.Source(document_count=1) f = factory.File(source=source) reply = factory.DraftReply(source=source) @@ -1224,7 +1273,7 @@ def test_SourceWidget_set_snippet(mocker, session_maker, session, homedir): Snippets are set as expected. """ mock_gui = mocker.MagicMock() - controller = logic.Controller('http://localhost', mock_gui, session_maker, homedir) + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir) source = factory.Source(document_count=1) f = factory.File(source=source) session.add(f) @@ -1255,7 +1304,9 @@ def test_SourceWidget_update_truncate_latest_msg(mocker): controller = mocker.MagicMock() source = mocker.MagicMock() source.journalist_designation = "Testy McTestface" - source.collection = [factory.Message(content="a" * 151), ] + source.collection = [ + factory.Message(content="a" * 151), + ] sw = SourceWidget(controller, source) sw.update() @@ -1266,13 +1317,13 @@ def test_SourceWidget_delete_source(mocker, session, source): mock_delete_source_message_box_object = mocker.MagicMock(DeleteSourceMessageBox) mock_controller = mocker.MagicMock() mock_delete_source_message = mocker.MagicMock( - return_value=mock_delete_source_message_box_object) + return_value=mock_delete_source_message_box_object + ) - sw = SourceWidget(mock_controller, source['source']) + sw = SourceWidget(mock_controller, source["source"]) mocker.patch( - "securedrop_client.gui.widgets.DeleteSourceMessageBox", - mock_delete_source_message, + "securedrop_client.gui.widgets.DeleteSourceMessageBox", mock_delete_source_message, ) sw.delete_source(None) @@ -1280,7 +1331,7 @@ def test_SourceWidget_delete_source(mocker, session, source): def test_SourceWidget_delete_source_when_user_chooses_cancel(mocker, session, source): - source = source['source'] # to get the Source object + source = source["source"] # to get the Source object file_ = factory.File(source=source) session.add(file_) message = factory.Message(source=source) @@ -1294,8 +1345,7 @@ def test_SourceWidget_delete_source_when_user_chooses_cancel(mocker, session, so sw = SourceWidget(mock_controller, source) mocker.patch( - "securedrop_client.gui.widgets.QMessageBox.question", - mock_message_box_question, + "securedrop_client.gui.widgets.QMessageBox.question", mock_message_box_question, ) sw.delete_source(None) sw.controller.delete_source.assert_not_called() @@ -1303,8 +1353,8 @@ def test_SourceWidget_delete_source_when_user_chooses_cancel(mocker, session, so def test_SourceWidget__on_source_deleted(mocker, session, source): controller = mocker.MagicMock() - sw = SourceWidget(controller, factory.Source(uuid='123')) - sw._on_source_deleted('123') + sw = SourceWidget(controller, factory.Source(uuid="123")) + sw._on_source_deleted("123") assert sw.gutter.isHidden() assert sw.metadata.isHidden() assert sw.preview.isHidden() @@ -1313,8 +1363,8 @@ def test_SourceWidget__on_source_deleted(mocker, session, source): def test_SourceWidget__on_source_deleted_wrong_uuid(mocker, session, source): controller = mocker.MagicMock() - sw = SourceWidget(controller, factory.Source(uuid='123')) - sw._on_source_deleted('321') + sw = SourceWidget(controller, factory.Source(uuid="123")) + sw._on_source_deleted("321") assert not sw.gutter.isHidden() assert not sw.metadata.isHidden() assert not sw.preview.isHidden() @@ -1323,10 +1373,10 @@ def test_SourceWidget__on_source_deleted_wrong_uuid(mocker, session, source): def test_SourceWidget__on_source_deletion_failed(mocker, session, source): controller = mocker.MagicMock() - sw = SourceWidget(controller, factory.Source(uuid='123')) - sw._on_source_deleted('123') + sw = SourceWidget(controller, factory.Source(uuid="123")) + sw._on_source_deleted("123") - sw._on_source_deletion_failed('123') + sw._on_source_deletion_failed("123") assert not sw.gutter.isHidden() assert not sw.metadata.isHidden() @@ -1336,10 +1386,10 @@ def test_SourceWidget__on_source_deletion_failed(mocker, session, source): def test_SourceWidget__on_source_deletion_failed_wrong_uuid(mocker, session, source): controller = mocker.MagicMock() - sw = SourceWidget(controller, factory.Source(uuid='123')) - sw._on_source_deleted('123') + sw = SourceWidget(controller, factory.Source(uuid="123")) + sw._on_source_deleted("123") - sw._on_source_deletion_failed('321') + sw._on_source_deletion_failed("321") assert sw.gutter.isHidden() assert sw.metadata.isHidden() @@ -1354,7 +1404,9 @@ def test_SourceWidget_uses_SecureQLabel(mocker): controller = mocker.MagicMock() source = mocker.MagicMock() source.journalist_designation = "Testy McTestface" - source.collection = [factory.Message(content="a" * 121), ] + source.collection = [ + factory.Message(content="a" * 121), + ] sw = SourceWidget(controller, source) sw.update() @@ -1392,7 +1444,7 @@ def test_StarToggleButton_eventFilter_when_checked(mocker): """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.pressed = mocker.MagicMock() stb.setIcon = mocker.MagicMock() stb.set_icon = mocker.MagicMock() @@ -1402,12 +1454,12 @@ def test_StarToggleButton_eventFilter_when_checked(mocker): test_event = QEvent(QEvent.HoverEnter) stb.eventFilter(stb, test_event) assert stb.setIcon.call_count == 1 - load_icon_fn.assert_called_once_with('star_hover.svg') + load_icon_fn.assert_called_once_with("star_hover.svg") # Hover leave test_event = QEvent(QEvent.HoverLeave) stb.eventFilter(stb, test_event) - stb.set_icon.assert_called_once_with(on='star_on.svg', off='star_off.svg') + stb.set_icon.assert_called_once_with(on="star_on.svg", off="star_off.svg") # Authentication change stb.on_authentication_changed(authenticated=True) @@ -1422,7 +1474,7 @@ def test_StarToggleButton_eventFilter_when_not_checked(mocker): """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.pressed = mocker.MagicMock() stb.setIcon = mocker.MagicMock() stb.set_icon = mocker.MagicMock() @@ -1432,12 +1484,12 @@ def test_StarToggleButton_eventFilter_when_not_checked(mocker): test_event = QEvent(QEvent.HoverEnter) stb.eventFilter(stb, test_event) assert stb.setIcon.call_count == 1 - load_icon_fn.assert_called_once_with('star_hover.svg') + load_icon_fn.assert_called_once_with("star_hover.svg") # Hover leave test_event = QEvent(QEvent.HoverLeave) stb.eventFilter(stb, test_event) - stb.set_icon.assert_called_once_with(on='star_on.svg', off='star_off.svg') + stb.set_icon.assert_called_once_with(on="star_on.svg", off="star_off.svg") # Authentication change stb.on_authentication_changed(authenticated=True) @@ -1452,7 +1504,7 @@ def test_StarToggleButton_eventFilter_when_checked_and_offline(mocker): off='star_on.svg' when checked and offline. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.pressed = mocker.MagicMock() stb.setIcon = mocker.MagicMock() stb.set_icon = mocker.MagicMock() @@ -1461,7 +1513,7 @@ def test_StarToggleButton_eventFilter_when_checked_and_offline(mocker): # Authentication change stb.on_authentication_changed(authenticated=False) assert stb.isCheckable() is False - stb.set_icon.assert_called_with(on='star_on.svg', off='star_on.svg') + stb.set_icon.assert_called_with(on="star_on.svg", off="star_on.svg") stb.pressed.disconnect.assert_called_once_with() stb.pressed.connect.assert_called_once_with(stb.on_pressed_offline) @@ -1483,7 +1535,7 @@ def test_StarToggleButton_eventFilter_when_not_checked_and_offline(mocker): off='star_on.svg' when unchecked and offline. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.pressed = mocker.MagicMock() stb.setIcon = mocker.MagicMock() stb.set_icon = mocker.MagicMock() @@ -1513,7 +1565,7 @@ def test_StarToggleButton_on_authentication_changed_while_authenticated_and_chec in the button being unchecked. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.on_pressed = mocker.MagicMock() stb.on_authentication_changed(authenticated=True) @@ -1529,7 +1581,7 @@ def test_StarToggleButton_on_authentication_changed_while_authenticated_and_not_ should result in the button being unchecked. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.on_pressed = mocker.MagicMock() stb.on_authentication_changed(authenticated=True) @@ -1544,7 +1596,7 @@ def test_StarToggleButton_on_authentication_changed_while_offline_mode_and_not_c Ensure on_authentication_changed is set up correctly for offline mode. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.on_pressed_offline = mocker.MagicMock() stb.on_pressed = mocker.MagicMock() stb.on_authentication_changed(authenticated=False) @@ -1561,7 +1613,7 @@ def test_StarToggleButton_on_authentication_changed_while_offline_mode_and_check Ensure on_authentication_changed is set up correctly for offline mode. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.on_pressed_offline = mocker.MagicMock() stb.on_pressed = mocker.MagicMock() stb.on_authentication_changed(authenticated=False) @@ -1579,11 +1631,11 @@ def test_StarToggleButton_on_pressed_toggles_to_starred(mocker): Ensure pressing the star button toggles from unstarred to starred. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.click() - stb.controller.update_star.assert_called_once_with('mock_uuid', False) + stb.controller.update_star.assert_called_once_with("mock_uuid", False) assert stb.isChecked() @@ -1592,11 +1644,11 @@ def test_StarToggleButton_on_pressed_toggles_to_unstarred(mocker): Ensure pressing the star button toggles from starred to unstarred. """ controller = mocker.MagicMock() - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.click() - stb.controller.update_star.assert_called_once_with('mock_uuid', True) + stb.controller.update_star.assert_called_once_with("mock_uuid", True) assert not stb.isChecked() @@ -1606,7 +1658,7 @@ def test_StarToggleButton_on_pressed_offline(mocker): """ controller = mocker.MagicMock() controller.is_authenticated = False - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.click() @@ -1621,11 +1673,11 @@ def test_StarToggleButton_on_pressed_offline_when_checked(mocker): controller.is_authenticated = False source = factory.Source(is_starred=True) stb = StarToggleButton(controller, source.uuid, source.is_starred) - set_icon_fn = mocker.patch('securedrop_client.gui.SvgToggleButton.set_icon') + set_icon_fn = mocker.patch("securedrop_client.gui.SvgToggleButton.set_icon") stb.on_authentication_changed(False) assert stb.isCheckable() is False - set_icon_fn.assert_called_with(on='star_on.svg', off='star_on.svg') + set_icon_fn.assert_called_with(on="star_on.svg", off="star_on.svg") stb.click() stb.controller.on_action_requiring_login.assert_called_once_with() @@ -1639,7 +1691,7 @@ def test_StarToggleButton_update(mocker): """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) # Should not change because we wait until next sync stb.pending_count = 0 @@ -1695,64 +1747,64 @@ def test_StarToggleButton_update_when_not_authenticated(mocker): def test_StarToggleButton_on_star_update_failed(mocker): - ''' + """ Ensure the button is toggled to the state provided in the failure handler and that the pending count is decremented if the source uuid matches. - ''' + """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.click() assert stb.is_starred is True assert stb.pending_count == 1 - stb.on_star_update_failed('mock_uuid', is_starred=False) + stb.on_star_update_failed("mock_uuid", is_starred=False) assert stb.is_starred is False assert stb.pending_count == 0 def test_StarToggleButton_on_star_update_failed_for_non_matching_source_uuid(mocker): - ''' + """ Ensure the button is not toggled and that the pending count stays the same if the source uuid does not match. - ''' + """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', False) + stb = StarToggleButton(controller, "mock_uuid", False) stb.click() assert stb.is_starred is True assert stb.pending_count == 1 - stb.on_star_update_failed('some_other_uuid', is_starred=False) + stb.on_star_update_failed("some_other_uuid", is_starred=False) assert stb.is_starred is True assert stb.pending_count == 1 def test_StarToggleButton_on_star_update_successful(mocker): - ''' + """ Ensure that the pending count is decremented if the source uuid matches. - ''' + """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.click() assert stb.pending_count == 1 - stb.on_star_update_successful('mock_uuid') + stb.on_star_update_successful("mock_uuid") assert stb.pending_count == 0 def test_StarToggleButton_on_star_update_successful_for_non_matching_source_uuid(mocker): - ''' + """ Ensure that the pending count is not decremented if the source uuid does not match. - ''' + """ controller = mocker.MagicMock() controller.is_authenticated = True - stb = StarToggleButton(controller, 'mock_uuid', True) + stb = StarToggleButton(controller, "mock_uuid", True) stb.click() assert stb.pending_count == 1 - stb.on_star_update_successful('some_other_uuid') + stb.on_star_update_successful("some_other_uuid") assert stb.pending_count == 1 @@ -1786,9 +1838,9 @@ def test_LoginDialog_reset(mocker): ld.reset() - ld.username_field.setText.assert_called_once_with('') - ld.password_field.setText.assert_called_once_with('') - ld.tfa_field.setText.assert_called_once_with('') + ld.username_field.setText.assert_called_once_with("") + ld.password_field.setText.assert_called_once_with("") + ld.tfa_field.setText.assert_called_once_with("") ld.setDisabled.assert_called_once_with(False) ld.error_bar.clear_message.assert_called_once_with() @@ -1801,8 +1853,8 @@ def test_LoginDialog_error(mocker, i18n): ld = LoginDialog(None) ld.setup(mock_controller) ld.error_bar = mocker.MagicMock() - ld.error('foo') - ld.error_bar.set_message.assert_called_once_with('foo') + ld.error("foo") + ld.error_bar.set_message.assert_called_once_with("foo") def test_LoginDialog_validate_no_input(mocker): @@ -1813,9 +1865,9 @@ def test_LoginDialog_validate_no_input(mocker): ld = LoginDialog(None) ld.setup(mock_controller) - ld.username_field.text = mocker.MagicMock(return_value='') - ld.password_field.text = mocker.MagicMock(return_value='') - ld.tfa_field.text = mocker.MagicMock(return_value='') + ld.username_field.text = mocker.MagicMock(return_value="") + ld.password_field.text = mocker.MagicMock(return_value="") + ld.tfa_field.text = mocker.MagicMock(return_value="") ld.setDisabled = mocker.MagicMock() ld.error = mocker.MagicMock() @@ -1834,9 +1886,9 @@ def test_LoginDialog_validate_input_non_numeric_2fa(mocker): ld = LoginDialog(None) ld.setup(mock_controller) - ld.username_field.text = mocker.MagicMock(return_value='foo') - ld.password_field.text = mocker.MagicMock(return_value='nicelongpassword') - ld.tfa_field.text = mocker.MagicMock(return_value='baz') + ld.username_field.text = mocker.MagicMock(return_value="foo") + ld.password_field.text = mocker.MagicMock(return_value="nicelongpassword") + ld.tfa_field.text = mocker.MagicMock(return_value="baz") ld.setDisabled = mocker.MagicMock() ld.error = mocker.MagicMock() @@ -1855,9 +1907,9 @@ def test_LoginDialog_validate_too_short_username(mocker): ld = LoginDialog(None) ld.setup(mock_controller) - ld.username_field.text = mocker.MagicMock(return_value='he') - ld.password_field.text = mocker.MagicMock(return_value='nicelongpassword') - ld.tfa_field.text = mocker.MagicMock(return_value='123456') + ld.username_field.text = mocker.MagicMock(return_value="he") + ld.password_field.text = mocker.MagicMock(return_value="nicelongpassword") + ld.tfa_field.text = mocker.MagicMock(return_value="123456") ld.setDisabled = mocker.MagicMock() ld.error = mocker.MagicMock() @@ -1876,9 +1928,9 @@ def test_LoginDialog_validate_too_short_password(mocker): ld = LoginDialog(None) ld.setup(mock_controller) - ld.username_field.text = mocker.MagicMock(return_value='foo') - ld.password_field.text = mocker.MagicMock(return_value='bar') - ld.tfa_field.text = mocker.MagicMock(return_value='123456') + ld.username_field.text = mocker.MagicMock(return_value="foo") + ld.password_field.text = mocker.MagicMock(return_value="bar") + ld.tfa_field.text = mocker.MagicMock(return_value="123456") ld.setDisabled = mocker.MagicMock() ld.error = mocker.MagicMock() @@ -1898,11 +1950,11 @@ def test_LoginDialog_validate_too_long_password(mocker): ld.setup(mock_controller) max_password_len = 128 - too_long_password = 'a' * (max_password_len + 1) + too_long_password = "a" * (max_password_len + 1) - ld.username_field.text = mocker.MagicMock(return_value='foo') + ld.username_field.text = mocker.MagicMock(return_value="foo") ld.password_field.text = mocker.MagicMock(return_value=too_long_password) - ld.tfa_field.text = mocker.MagicMock(return_value='123456') + ld.tfa_field.text = mocker.MagicMock(return_value="123456") ld.setDisabled = mocker.MagicMock() ld.error = mocker.MagicMock() @@ -1921,9 +1973,9 @@ def test_LoginDialog_validate_input_ok(mocker): ld = LoginDialog(None) ld.setup(mock_controller) - ld.username_field.text = mocker.MagicMock(return_value='foo') - ld.password_field.text = mocker.MagicMock(return_value='nicelongpassword') - ld.tfa_field.text = mocker.MagicMock(return_value='123456') + ld.username_field.text = mocker.MagicMock(return_value="foo") + ld.password_field.text = mocker.MagicMock(return_value="nicelongpassword") + ld.tfa_field.text = mocker.MagicMock(return_value="123456") ld.setDisabled = mocker.MagicMock() ld.error = mocker.MagicMock() @@ -1931,7 +1983,7 @@ def test_LoginDialog_validate_input_ok(mocker): assert ld.setDisabled.call_count == 1 assert ld.error.call_count == 0 - mock_controller.login.assert_called_once_with('foo', 'nicelongpassword', '123456') + mock_controller.login.assert_called_once_with("foo", "nicelongpassword", "123456") def test_LoginDialog_escapeKeyPressEvent(mocker): @@ -1971,10 +2023,10 @@ def test_LoginDialog_closeEvent_exits(mocker): """ mw = QMainWindow() ld = LoginDialog(mw) - sys_exit_fn = mocker.patch('securedrop_client.gui.widgets.sys.exit') + sys_exit_fn = mocker.patch("securedrop_client.gui.widgets.sys.exit") mw.hide() - ld.closeEvent(event='mock') + ld.closeEvent(event="mock") sys_exit_fn.assert_called_once_with(0) @@ -1982,22 +2034,22 @@ def test_LoginDialog_closeEvent_exits(mocker): def test_LoginErrorBar_set_message(mocker): error_bar = LoginErrorBar() error_bar.error_status_bar = mocker.MagicMock() - mocker.patch.object(error_bar, 'show') + mocker.patch.object(error_bar, "show") - error_bar.set_message('mock error') + error_bar.set_message("mock error") - error_bar.error_status_bar.setText.assert_called_with('mock error') + error_bar.error_status_bar.setText.assert_called_with("mock error") error_bar.show.assert_called_with() def test_LoginErrorBar_clear_message(mocker): error_bar = LoginErrorBar() error_bar.error_status_bar = mocker.MagicMock() - mocker.patch.object(error_bar, 'hide') + mocker.patch.object(error_bar, "hide") error_bar.clear_message() - error_bar.error_status_bar.setText.assert_called_with('') + error_bar.error_status_bar.setText.assert_called_with("") error_bar.hide.assert_called_with() @@ -2020,10 +2072,10 @@ def test_LoginDialog_closeEvent_does_not_exit_when_main_window_is_visible(mocker """ mw = QMainWindow() ld = LoginDialog(mw) - sys_exit_fn = mocker.patch('securedrop_client.gui.widgets.sys.exit') + sys_exit_fn = mocker.patch("securedrop_client.gui.widgets.sys.exit") mw.show() - ld.closeEvent(event='mock') + ld.closeEvent(event="mock") assert sys_exit_fn.called is False @@ -2041,9 +2093,9 @@ def test_SpeechBubble_init(mocker): mock_download_error_connect = mocker.Mock() mock_download_error_signal.connect = mock_download_error_connect - sb = SpeechBubble('mock id', 'hello', mock_update_signal, mock_download_error_signal, 0) + sb = SpeechBubble("mock id", "hello", mock_update_signal, mock_download_error_signal, 0) - sb.message.text() == 'hello' + sb.message.text() == "hello" assert mock_update_connect.called assert mock_download_error_connect.called @@ -2061,10 +2113,10 @@ def test_SpeechBubble_init_with_error(mocker): mock_download_error_signal.connect = mock_download_error_connect sb = SpeechBubble( - 'mock id', 'hello', mock_update_signal, mock_download_error_signal, 0, error=True + "mock id", "hello", mock_update_signal, mock_download_error_signal, 0, error=True ) - sb.message.text() == 'hello' + sb.message.text() == "hello" assert mock_update_connect.called assert mock_download_error_connect.called @@ -2075,15 +2127,15 @@ def test_SpeechBubble_update_text(mocker): """ mock_signal = mocker.MagicMock() - msg_id = 'abc123' - sb = SpeechBubble(msg_id, 'hello', mock_signal, mock_signal, 0) + msg_id = "abc123" + sb = SpeechBubble(msg_id, "hello", mock_signal, mock_signal, 0) - new_msg = 'new message' - sb._update_text('mock_source_uuid', msg_id, new_msg) + new_msg = "new message" + sb._update_text("mock_source_uuid", msg_id, new_msg) assert sb.message.text() == new_msg - newer_msg = 'an even newer message' - sb._update_text('mock_source_uuid', msg_id + 'xxxxx', newer_msg) + newer_msg = "an even newer message" + sb._update_text("mock_source_uuid", msg_id + "xxxxx", newer_msg) assert sb.message.text() == new_msg @@ -2094,8 +2146,8 @@ def test_SpeechBubble_html_init(mocker): """ mock_signal = mocker.MagicMock() - bubble = SpeechBubble('mock id', 'hello', mock_signal, mock_signal, 0) - assert bubble.message.text() == 'hello' + bubble = SpeechBubble("mock id", "hello", mock_signal, mock_signal, 0) + assert bubble.message.text() == "hello" def test_SpeechBubble_with_apostrophe_in_text(mocker): @@ -2103,7 +2155,7 @@ def test_SpeechBubble_with_apostrophe_in_text(mocker): mock_signal = mocker.MagicMock() message = "I'm sure, you are reading my message." - bubble = SpeechBubble('mock id', message, mock_signal, mock_signal, 0) + bubble = SpeechBubble("mock id", message, mock_signal, mock_signal, 0) assert bubble.message.text() == message @@ -2128,7 +2180,7 @@ def test_MessageWidget_init(mocker): mock_connected = mocker.Mock() mock_signal.connect = mock_connected - MessageWidget('mock id', 'hello', mock_signal, mock_signal, 0) + MessageWidget("mock id", "hello", mock_signal, mock_signal, 0) assert mock_connected.called @@ -2154,9 +2206,9 @@ def test_ReplyWidget_init(mocker): mock_failure_signal.connect = mock_failure_connected ReplyWidget( - 'mock id', - 'hello', - 'dummy', + "mock id", + "hello", + "dummy", mock_update_signal, mock_download_failure_signal, mock_success_signal, @@ -2190,15 +2242,15 @@ def test_ReplyWidget_init_with_error(mocker): mock_failure_signal.connect = mock_failure_connected ReplyWidget( - 'mock id', - 'hello', - 'dummy', + "mock id", + "hello", + "dummy", mock_update_signal, mock_download_failure_signal, mock_success_signal, mock_failure_signal, 0, - error=True + error=True, ) assert mock_update_connected.called @@ -2210,14 +2262,14 @@ def test_FileWidget_init_file_not_downloaded(mocker, source, session): """ Check the FileWidget is configured correctly when the file is not downloaded. """ - file = factory.File(source=source['source'], is_downloaded=False, is_decrypted=None) + file = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file) session.commit() get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) - fw = FileWidget('mock', controller, mocker.MagicMock(), mocker.MagicMock(), 0) + fw = FileWidget("mock", controller, mocker.MagicMock(), mocker.MagicMock(), 0) assert fw.controller == controller assert fw.file.is_downloaded is False @@ -2233,14 +2285,14 @@ def test_FileWidget_init_file_downloaded(mocker, source, session): """ Check the FileWidget is configured correctly when the file is downloaded. """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) - fw = FileWidget('mock', controller, mocker.MagicMock(), mocker.MagicMock(), 0) + fw = FileWidget("mock", controller, mocker.MagicMock(), mocker.MagicMock(), 0) assert fw.controller == controller assert fw.file.is_downloaded is True @@ -2257,9 +2309,7 @@ def test_FileWidget__set_file_state_under_mouse(mocker, source, session): If the download_button is under the mouse, it should show the "hover" version of the download_file icon. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() @@ -2279,9 +2329,7 @@ def test_FileWidget_event_handler_left_click(mocker, session, source): """ Left click on filename should trigger an open. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() @@ -2302,9 +2350,7 @@ def test_FileWidget_event_handler_hover(mocker, session, source): Hover events when the file isn't being downloaded should change the widget's icon. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() @@ -2331,9 +2377,7 @@ def test_FileWidget_on_left_click_download(mocker, session, source): Left click on download when file is not downloaded should trigger a download. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() @@ -2347,8 +2391,7 @@ def test_FileWidget_on_left_click_download(mocker, session, source): fw._on_left_click() mock_get_file.assert_called_once_with(file_.uuid) - mock_controller.on_submission_download.assert_called_once_with( - db.File, file_.uuid) + mock_controller.on_submission_download.assert_called_once_with(db.File, file_.uuid) def test_FileWidget_on_left_click_downloading_in_progress(mocker, session, source): @@ -2356,9 +2399,7 @@ def test_FileWidget_on_left_click_downloading_in_progress(mocker, session, sourc Left click on download when file is not downloaded but is in progress downloading should not trigger a download. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() @@ -2380,9 +2421,7 @@ def test_FileWidget_start_button_animation(mocker, session, source): """ Ensure widget state is updated when this method is called. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() mock_get_file = mocker.MagicMock(return_value=file_) @@ -2398,7 +2437,7 @@ def test_FileWidget_on_left_click_open(mocker, session, source): """ Left click on open when file is downloaded should trigger an open. """ - file_ = factory.File(source=source['source'], is_downloaded=True) + file_ = factory.File(source=source["source"], is_downloaded=True) session.add(file_) session.commit() @@ -2415,9 +2454,7 @@ def test_FileWidget_set_button_animation_frame(mocker, session, source): Left click on download when file is not downloaded should trigger a download. """ - file_ = factory.File(source=source['source'], - is_downloaded=False, - is_decrypted=None) + file_ = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None) session.add(file_) session.commit() @@ -2434,7 +2471,7 @@ def test_FileWidget_update(mocker, session, source): """ The update method should show/hide widgets if file is downloaded """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() get_file = mocker.MagicMock(return_value=file) @@ -2452,7 +2489,7 @@ def test_FileWidget_on_file_download_updates_items_when_uuid_matches(mocker, sou """ The _on_file_download method should update the FileWidget """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() @@ -2479,7 +2516,7 @@ def test_FileWidget_filename_truncation(mocker, source, session): The full filename should be available in the tooltip. """ filename = "1-{}".format("x" * 1000) - file = factory.File(source=source['source'], filename=filename) + file = factory.File(source=source["source"], filename=filename) session.add(file) session.commit() @@ -2501,7 +2538,7 @@ def test_FileWidget_on_file_download_updates_items_when_uuid_does_not_match( """ The _on_file_download method should clear and update the FileWidget """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() @@ -2512,7 +2549,7 @@ def test_FileWidget_on_file_download_updates_items_when_uuid_does_not_match( fw.clear = mocker.MagicMock() fw.update = mocker.MagicMock() - fw._on_file_downloaded('not a matching source uuid', 'not a matching file uuid', 'mock') + fw._on_file_downloaded("not a matching source uuid", "not a matching file uuid", "mock") fw.clear.assert_not_called() assert fw.download_button.isHidden() @@ -2524,25 +2561,19 @@ def test_FileWidget_on_file_download_updates_items_when_uuid_does_not_match( def test_FileWidget_on_file_missing_show_download_button_when_uuid_matches( - mocker, source, session, session_maker, homedir + mocker, source, session, session_maker, homedir ): """ The _on_file_missing method should update the FileWidget when uuid matches. """ - file = factory.File(source=source['source'], is_decrypted=None, is_downloaded=False) + file = factory.File(source=source["source"], is_decrypted=None, is_downloaded=False) session.add(file) session.commit() mock_gui = mocker.MagicMock() - controller = logic.Controller('http://localhost', mock_gui, session_maker, homedir) - - fw = FileWidget( - file.uuid, - controller, - controller.file_ready, - controller.file_missing, - 0 - ) + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir) + + fw = FileWidget(file.uuid, controller, controller.file_ready, controller.file_missing, 0) fw._on_file_missing(file.source.uuid, file.uuid, str(file)) # this is necessary for the timer that stops the download @@ -2564,7 +2595,7 @@ def test_FileWidget_on_file_missing_does_not_show_download_button_when_uuid_does """ The _on_file_missing method should not update the FileWidget when uuid doesn't match. """ - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() @@ -2574,7 +2605,7 @@ def test_FileWidget_on_file_missing_does_not_show_download_button_when_uuid_does fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) fw.download_button.show = mocker.MagicMock() - fw._on_file_missing('not a matching source uuid', 'not a matching file uuid', 'mock filename') + fw._on_file_missing("not a matching source uuid", "not a matching file uuid", "mock filename") fw.download_button.show.assert_not_called() @@ -2583,7 +2614,7 @@ def test_FileWidget__on_export_clicked(mocker, session, source): """ Ensure preflight checks start when the EXPORT button is clicked and that password is requested """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() @@ -2592,11 +2623,11 @@ def test_FileWidget__on_export_clicked(mocker, session, source): fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) fw.update = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.QDialog.exec') + mocker.patch("securedrop_client.gui.widgets.QDialog.exec") controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - dialog = mocker.patch('securedrop_client.gui.widgets.ExportDialog') + dialog = mocker.patch("securedrop_client.gui.widgets.ExportDialog") fw._on_export_clicked() dialog.assert_called_once_with(controller, file.uuid, file.filename) @@ -2606,7 +2637,7 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): """ Ensure dialog does not open when the EXPORT button is clicked yet the file to export is missing """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() @@ -2615,10 +2646,10 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) fw.update = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.QDialog.exec') + mocker.patch("securedrop_client.gui.widgets.QDialog.exec") controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=False) - dialog = mocker.patch('securedrop_client.gui.widgets.ExportDialog') + dialog = mocker.patch("securedrop_client.gui.widgets.ExportDialog") fw._on_export_clicked() @@ -2630,7 +2661,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source): """ Ensure print_file is called when the PRINT button is clicked """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() @@ -2639,11 +2670,11 @@ def test_FileWidget__on_print_clicked(mocker, session, source): fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) fw.update = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.QDialog.exec') + mocker.patch("securedrop_client.gui.widgets.QDialog.exec") controller.print_file = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - dialog = mocker.patch('securedrop_client.gui.widgets.PrintDialog') + dialog = mocker.patch("securedrop_client.gui.widgets.PrintDialog") fw._on_print_clicked() @@ -2654,7 +2685,7 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): """ Ensure dialog does not open when the EXPORT button is clicked yet the file to export is missing """ - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() @@ -2663,10 +2694,10 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) fw.update = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.QDialog.exec') + mocker.patch("securedrop_client.gui.widgets.QDialog.exec") controller.print_file = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=False) - dialog = mocker.patch('securedrop_client.gui.widgets.PrintDialog') + dialog = mocker.patch("securedrop_client.gui.widgets.PrintDialog") fw._on_print_clicked() @@ -2678,17 +2709,16 @@ def test_FileWidget_update_file_size_with_deleted_file( mocker, homedir, config, session_maker, source ): mock_gui = mocker.MagicMock() - controller = logic.Controller('http://localhost', mock_gui, session_maker, homedir) + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir) - file = factory.File(source=source['source'], is_downloaded=True) + file = factory.File(source=source["source"], is_downloaded=True) controller.session.add(file) controller.session.commit() fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) with mocker.patch( - "securedrop_client.gui.widgets.humanize_filesize", - side_effect=Exception("boom!") + "securedrop_client.gui.widgets.humanize_filesize", side_effect=Exception("boom!") ): fw.update_file_size() assert fw.file_size.text() == "" @@ -2799,47 +2829,50 @@ def test_ModalDialog_animation_of_header(mocker): def test_ExportDialog_init(mocker): _show_starting_instructions_fn = mocker.patch( - 'securedrop_client.gui.widgets.ExportDialog._show_starting_instructions') + "securedrop_client.gui.widgets.ExportDialog._show_starting_instructions" + ) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") _show_starting_instructions_fn.assert_called_once_with() assert dialog.passphrase_form.isHidden() def test_ExportDialog_init_sanitizes_filename(mocker): - secure_qlabel = mocker.patch('securedrop_client.gui.widgets.SecureQLabel') - mocker.patch('securedrop_client.gui.widgets.QVBoxLayout.addWidget') + secure_qlabel = mocker.patch("securedrop_client.gui.widgets.SecureQLabel") + mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") filename = '' - ExportDialog(mocker.MagicMock(), 'mock_uuid', filename) + ExportDialog(mocker.MagicMock(), "mock_uuid", filename) secure_qlabel.call_args_list[1].assert_called_with(filename) def test_ExportDialog__show_starting_instructions(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_starting_instructions() - assert dialog.header.text() == \ - 'Preparing to export:' \ - '
' \ + assert ( + dialog.header.text() == "Preparing to export:" + "
" 'mock.jpg' - assert dialog.body.text() == \ - '

Understand the risks before exporting files

' \ - 'Malware' \ - '
' \ - 'This workstation lets you open files securely. If you open files on another ' \ - 'computer, any embedded malware may spread to your computer or network. If you are ' \ - 'unsure how to manage this risk, please print the file, or contact your ' \ - 'administrator.' \ - '

' \ - 'Anonymity' \ - '
' \ - 'Files submitted by sources may contain information or hidden metadata that ' \ - 'identifies who they are. To protect your sources, please consider redacting files ' \ - 'before working with them on network-connected computers.' + ) + assert ( + dialog.body.text() == "

Understand the risks before exporting files

" + "Malware" + "
" + "This workstation lets you open files securely. If you open files on another " + "computer, any embedded malware may spread to your computer or network. If you are " + "unsure how to manage this risk, please print the file, or contact your " + "administrator." + "

" + "Anonymity" + "
" + "Files submitted by sources may contain information or hidden metadata that " + "identifies who they are. To protect your sources, please consider redacting files " + "before working with them on network-connected computers." + ) assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -2850,11 +2883,11 @@ def test_ExportDialog__show_starting_instructions(mocker): def test_ExportDialog___show_passphrase_request_message(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_passphrase_request_message() - assert dialog.header.text() == 'Enter passphrase for USB drive' + assert dialog.header.text() == "Enter passphrase for USB drive" assert not dialog.header.isHidden() assert dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -2865,12 +2898,12 @@ def test_ExportDialog___show_passphrase_request_message(mocker): def test_ExportDialog__show_passphrase_request_message_again(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_passphrase_request_message_again() - assert dialog.header.text() == 'Enter passphrase for USB drive' - assert dialog.error_details.text() == 'The passphrase provided did not work. Please try again.' + assert dialog.header.text() == "Enter passphrase for USB drive" + assert dialog.error_details.text() == "The passphrase provided did not work. Please try again." assert dialog.body.isHidden() assert not dialog.header.isHidden() assert dialog.header_line.isHidden() @@ -2882,13 +2915,15 @@ def test_ExportDialog__show_passphrase_request_message_again(mocker): def test_ExportDialog__show_success_message(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_success_message() - assert dialog.header.text() == 'Export successful' - assert dialog.body.text() == \ - 'Remember to be careful when working with files outside of your Workstation machine.' + assert dialog.header.text() == "Export successful" + assert ( + dialog.body.text() + == "Remember to be careful when working with files outside of your Workstation machine." + ) assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -2899,14 +2934,15 @@ def test_ExportDialog__show_success_message(mocker): def test_ExportDialog__show_insert_usb_message(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_insert_usb_message() - assert dialog.header.text() == 'Insert encrypted USB drive' - assert dialog.body.text() == \ - 'Please insert one of the export drives provisioned specifically ' \ - 'for the SecureDrop Workstation.' + assert dialog.header.text() == "Insert encrypted USB drive" + assert ( + dialog.body.text() == "Please insert one of the export drives provisioned specifically " + "for the SecureDrop Workstation." + ) assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -2917,16 +2953,20 @@ def test_ExportDialog__show_insert_usb_message(mocker): def test_ExportDialog__show_insert_encrypted_usb_message(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_insert_encrypted_usb_message() - assert dialog.header.text() == 'Insert encrypted USB drive' - assert dialog.error_details.text() == \ - 'Either the drive is not encrypted or there is something else wrong with it.' - assert dialog.body.text() == \ - 'Please insert one of the export drives provisioned specifically for the SecureDrop ' \ - 'Workstation.' + assert dialog.header.text() == "Insert encrypted USB drive" + assert ( + dialog.error_details.text() + == "Either the drive is not encrypted or there is something else wrong with it." + ) + assert ( + dialog.body.text() + == "Please insert one of the export drives provisioned specifically for the SecureDrop " + "Workstation." + ) assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert not dialog.error_details.isHidden() @@ -2937,13 +2977,13 @@ def test_ExportDialog__show_insert_encrypted_usb_message(mocker): def test_ExportDialog__show_generic_error_message(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog.error_status = 'mock_error_status' + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") + dialog.error_status = "mock_error_status" dialog._show_generic_error_message() - assert dialog.header.text() == 'Export failed' - assert dialog.body.text() == 'mock_error_status: See your administrator for help.' + assert dialog.header.text() == "Export failed" + assert dialog.body.text() == "mock_error_status: See your administrator for help." assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -2956,30 +2996,31 @@ def test_ExportDialog__show_generic_error_message(mocker): def test_ExportDialog__export_file(mocker): controller = mocker.MagicMock() controller.export_file_to_usb_drive = mocker.MagicMock() - dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') + dialog = ExportDialog(controller, "mock_uuid", "mock.jpg") + dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") dialog._export_file() - controller.export_file_to_usb_drive.assert_called_once_with('mock_uuid', 'mock_passphrase') + controller.export_file_to_usb_drive.assert_called_once_with("mock_uuid", "mock_passphrase") def test_ExportDialog__on_preflight_success(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_passphrase_request_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) dialog._on_preflight_success() dialog._show_passphrase_request_message.assert_not_called() dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_passphrase_request_message) + dialog._show_passphrase_request_message + ) def test_ExportDialog__on_preflight_success_when_continue_enabled(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_passphrase_request_message = mocker.MagicMock() dialog.continue_button.setEnabled(True) @@ -2989,31 +3030,31 @@ def test_ExportDialog__on_preflight_success_when_continue_enabled(mocker): def test_ExportDialog__on_preflight_success_enabled_after_preflight_success(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") assert not dialog.continue_button.isEnabled() dialog._on_preflight_success() assert dialog.continue_button.isEnabled() def test_ExportDialog__on_preflight_success_enabled_after_preflight_failure(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") assert not dialog.continue_button.isEnabled() dialog._on_preflight_failure(mocker.MagicMock()) assert dialog.continue_button.isEnabled() def test_ExportDialog__on_preflight_failure(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._update_dialog = mocker.MagicMock() - error = ExportError('mock_error_status') + error = ExportError("mock_error_status") dialog._on_preflight_failure(error) - dialog._update_dialog.assert_called_with('mock_error_status') + dialog._update_dialog.assert_called_with("mock_error_status") def test_ExportDialog__on_export_success(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_success_message = mocker.MagicMock() dialog._on_export_success() @@ -3022,148 +3063,155 @@ def test_ExportDialog__on_export_success(mocker): def test_ExportDialog__on_export_failure(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._update_dialog = mocker.MagicMock() - error = ExportError('mock_error_status') + error = ExportError("mock_error_status") dialog._on_export_failure(error) - dialog._update_dialog.assert_called_with('mock_error_status') + dialog._update_dialog.assert_called_with("mock_error_status") def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_insert_usb_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED.value) dialog.continue_button.clicked.connect.assert_called_once_with(dialog._show_insert_usb_message) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED.value) dialog._show_insert_usb_message.assert_called_once_with() def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_passphrase_request_message_again = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._update_dialog(ExportStatus.BAD_PASSPHRASE.value) dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_passphrase_request_message_again) + dialog._show_passphrase_request_message_again + ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._update_dialog(ExportStatus.BAD_PASSPHRASE.value) dialog._show_passphrase_request_message_again.assert_called_once_with() def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_insert_encrypted_usb_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_insert_encrypted_usb_message) + dialog._show_insert_encrypted_usb_message + ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) dialog._show_insert_encrypted_usb_message.assert_called_once_with() def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_generic_error_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR.value) dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_generic_error_message) + dialog._show_generic_error_message + ) assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR.value) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value def test_ExportDialog__update_dialog_when_status_is_unknown(mocker): - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = ExportDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_generic_error_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._update_dialog('Some Unknown Error Status') + dialog._update_dialog("Some Unknown Error Status") dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_generic_error_message) - assert dialog.error_status == 'Some Unknown Error Status' + dialog._show_generic_error_message + ) + assert dialog.error_status == "Some Unknown Error Status" # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._update_dialog('Some Unknown Error Status') + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) + dialog._update_dialog("Some Unknown Error Status") dialog._show_generic_error_message.assert_called_once_with() - assert dialog.error_status == 'Some Unknown Error Status' + assert dialog.error_status == "Some Unknown Error Status" def test_PrintDialog_init(mocker): _show_starting_instructions_fn = mocker.patch( - 'securedrop_client.gui.widgets.PrintDialog._show_starting_instructions') + "securedrop_client.gui.widgets.PrintDialog._show_starting_instructions" + ) - PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") _show_starting_instructions_fn.assert_called_once_with() def test_PrintDialog_init_sanitizes_filename(mocker): - secure_qlabel = mocker.patch('securedrop_client.gui.widgets.SecureQLabel') + secure_qlabel = mocker.patch("securedrop_client.gui.widgets.SecureQLabel") filename = '' - PrintDialog(mocker.MagicMock(), 'mock_uuid', filename) + PrintDialog(mocker.MagicMock(), "mock_uuid", filename) secure_qlabel.call_args_list[0].assert_called_with(filename) def test_PrintDialog__show_starting_instructions(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._show_starting_instructions() - assert dialog.header.text() == \ - 'Preparing to print:' \ - '
' \ + assert ( + dialog.header.text() == "Preparing to print:" + "
" 'mock.jpg' - assert dialog.body.text() == \ - '

Managing printout risks

' \ - 'QR codes and web addresses' \ - '
' \ - 'Never type in and open web addresses or scan QR codes contained in printed ' \ - 'documents without taking security precautions. If you are unsure how to ' \ - 'manage this risk, please contact your administrator.' \ - '

' \ - 'Printer dots' \ - '
' \ - 'Any part of a printed page may contain identifying information ' \ - 'invisible to the naked eye, such as printer dots. Please carefully ' \ - 'consider this risk when working with or publishing scanned printouts.' + ) + assert ( + dialog.body.text() == "

Managing printout risks

" + "QR codes and web addresses" + "
" + "Never type in and open web addresses or scan QR codes contained in printed " + "documents without taking security precautions. If you are unsure how to " + "manage this risk, please contact your administrator." + "

" + "Printer dots" + "
" + "Any part of a printed page may contain identifying information " + "invisible to the naked eye, such as printer dots. Please carefully " + "consider this risk when working with or publishing scanned printouts." + ) assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -3173,12 +3221,12 @@ def test_PrintDialog__show_starting_instructions(mocker): def test_PrintDialog__show_insert_usb_message(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_insert_usb_message() - assert dialog.header.text() == 'Connect USB printer' - assert dialog.body.text() == 'Please connect your printer to a USB port.' + assert dialog.header.text() == "Connect USB printer" + assert dialog.body.text() == "Please connect your printer to a USB port." assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -3188,13 +3236,13 @@ def test_PrintDialog__show_insert_usb_message(mocker): def test_PrintDialog__show_generic_error_message(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog.error_status = 'mock_error_status' + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") + dialog.error_status = "mock_error_status" dialog._show_generic_error_message() - assert dialog.header.text() == 'Printing failed' - assert dialog.body.text() == 'mock_error_status: See your administrator for help.' + assert dialog.header.text() == "Printing failed" + assert dialog.body.text() == "mock_error_status: See your administrator for help." assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -3204,7 +3252,7 @@ def test_PrintDialog__show_generic_error_message(mocker): def test_PrintDialog__print_file(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog.close = mocker.MagicMock() dialog._print_file() @@ -3213,11 +3261,11 @@ def test_PrintDialog__print_file(mocker): def test_PrintDialog__on_preflight_success(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._print_file = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) dialog._on_preflight_success() @@ -3226,7 +3274,7 @@ def test_PrintDialog__on_preflight_success(mocker): def test_PrintDialog__on_preflight_success_when_continue_enabled(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") dialog._print_file = mocker.MagicMock() dialog.continue_button.setEnabled(True) @@ -3236,99 +3284,102 @@ def test_PrintDialog__on_preflight_success_when_continue_enabled(mocker): def test_PrintDialog__on_preflight_success_enabled_after_preflight_success(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") assert not dialog.continue_button.isEnabled() dialog._on_preflight_success() assert dialog.continue_button.isEnabled() def test_PrintDialog__on_preflight_success_enabled_after_preflight_failure(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") assert not dialog.continue_button.isEnabled() dialog._on_preflight_failure(mocker.MagicMock()) assert dialog.continue_button.isEnabled() def test_PrintDialog__on_preflight_failure_when_status_is_PRINTER_NOT_FOUND(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_insert_usb_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._on_preflight_failure(ExportError(ExportStatus.PRINTER_NOT_FOUND.value)) dialog.continue_button.clicked.connect.assert_called_once_with(dialog._show_insert_usb_message) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._on_preflight_failure(ExportError(ExportStatus.PRINTER_NOT_FOUND.value)) dialog._show_insert_usb_message.assert_called_once_with() def test_PrintDialog__on_preflight_failure_when_status_is_MISSING_PRINTER_URI(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_generic_error_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._on_preflight_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_generic_error_message) + dialog._show_generic_error_message + ) assert dialog.error_status == ExportStatus.MISSING_PRINTER_URI.value # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._on_preflight_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == ExportStatus.MISSING_PRINTER_URI.value def test_PrintDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_generic_error_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_generic_error_message) + dialog._show_generic_error_message + ) assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value def test_PrintDialog__on_preflight_failure_when_status_is_unknown(mocker): - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), "mock_uuid", "mock_filename") dialog._show_generic_error_message = mocker.MagicMock() dialog.continue_button = mocker.MagicMock() dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + dialog._on_preflight_failure(ExportError("Some Unknown Error Status")) dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_generic_error_message) - assert dialog.error_status == 'Some Unknown Error Status' + dialog._show_generic_error_message + ) + assert dialog.error_status == "Some Unknown Error Status" # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + mocker.patch.object(dialog.continue_button, "isEnabled", return_value=True) + dialog._on_preflight_failure(ExportError("Some Unknown Error Status")) dialog._show_generic_error_message.assert_called_once_with() - assert dialog.error_status == 'Some Unknown Error Status' + assert dialog.error_status == "Some Unknown Error Status" def test_SourceConversationWrapper__on_source_deleted(mocker): - scw = SourceConversationWrapper(factory.Source(uuid='123'), mocker.MagicMock()) - scw._on_source_deleted('123') + scw = SourceConversationWrapper(factory.Source(uuid="123"), mocker.MagicMock()) + scw._on_source_deleted("123") assert scw.conversation_title_bar.isHidden() assert scw.conversation_view.isHidden() assert scw.reply_box.isHidden() @@ -3336,8 +3387,8 @@ def test_SourceConversationWrapper__on_source_deleted(mocker): def test_SourceConversationWrapper__on_source_deleted_wrong_uuid(mocker): - scw = SourceConversationWrapper(factory.Source(uuid='123'), mocker.MagicMock()) - scw._on_source_deleted('321') + scw = SourceConversationWrapper(factory.Source(uuid="123"), mocker.MagicMock()) + scw._on_source_deleted("321") assert not scw.conversation_title_bar.isHidden() assert not scw.conversation_view.isHidden() assert not scw.reply_box.isHidden() @@ -3345,10 +3396,10 @@ def test_SourceConversationWrapper__on_source_deleted_wrong_uuid(mocker): def test_SourceConversationWrapper__on_source_deletion_failed(mocker): - scw = SourceConversationWrapper(factory.Source(uuid='123'), mocker.MagicMock()) - scw._on_source_deleted('123') + scw = SourceConversationWrapper(factory.Source(uuid="123"), mocker.MagicMock()) + scw._on_source_deleted("123") - scw._on_source_deletion_failed('123') + scw._on_source_deletion_failed("123") assert not scw.conversation_title_bar.isHidden() assert not scw.conversation_view.isHidden() @@ -3357,10 +3408,10 @@ def test_SourceConversationWrapper__on_source_deletion_failed(mocker): def test_SourceConversationWrapper__on_source_deletion_failed_wrong_uuid(mocker): - scw = SourceConversationWrapper(factory.Source(uuid='123'), mocker.MagicMock()) - scw._on_source_deleted('123') + scw = SourceConversationWrapper(factory.Source(uuid="123"), mocker.MagicMock()) + scw._on_source_deleted("123") - scw._on_source_deletion_failed('321') + scw._on_source_deletion_failed("321") assert scw.conversation_title_bar.isHidden() assert scw.conversation_view.isHidden() @@ -3425,7 +3476,7 @@ def test_ConversationView_add_message(mocker, session, source): """ Adding a message results in a new MessageWidget added to the layout. """ - source = source['source'] # grab the source from the fixture dict for simplicity + source = source["source"] # grab the source from the fixture dict for simplicity mock_message_ready_signal = mocker.MagicMock() mock_message_download_failed_signal = mocker.MagicMock() @@ -3435,7 +3486,7 @@ def test_ConversationView_add_message(mocker, session, source): message_download_failed=mock_message_download_failed_signal, ) - content = 'a sea, a bee' + content = "a sea, a bee" message = factory.Message(source=source, content=content) session.add(message) session.commit() @@ -3446,8 +3497,9 @@ def test_ConversationView_add_message(mocker, session, source): # this is the MessageWidget that __init__() would return mock_msg_widget_res = mocker.MagicMock() # mock the actual MessageWidget so we can inspect the __init__ call - mock_msg_widget = mocker.patch('securedrop_client.gui.widgets.MessageWidget', - return_value=mock_msg_widget_res) + mock_msg_widget = mocker.patch( + "securedrop_client.gui.widgets.MessageWidget", return_value=mock_msg_widget_res + ) cv.add_message(message, 0) @@ -3463,7 +3515,8 @@ def test_ConversationView_add_message(mocker, session, source): # check that we added the correct widget to the layout cv.scroll.conversation_layout.insertWidget.assert_called_once_with( - 0, mock_msg_widget_res, alignment=Qt.AlignLeft) + 0, mock_msg_widget_res, alignment=Qt.AlignLeft + ) # Check the signal is emitted to say the message has been added (and thus # the timestamps need updating. @@ -3476,14 +3529,14 @@ def test_ConversationView_add_message_no_content(mocker, session, source): checks that if a `Message` has `content = None` that a helpful message is displayed as would be the case if download/decryption never occurred or failed. """ - source = source['source'] # grab the source from the fixture dict for simplicity + source = source["source"] # grab the source from the fixture dict for simplicity mock_message_ready_signal = mocker.MagicMock() mock_message_download_failed_signal = mocker.MagicMock() mocked_controller = mocker.MagicMock( session=session, message_ready=mock_message_ready_signal, - message_download_failed=mock_message_download_failed_signal + message_download_failed=mock_message_download_failed_signal, ) message = factory.Message(source=source, is_decrypted=False, content=None) @@ -3495,20 +3548,26 @@ def test_ConversationView_add_message_no_content(mocker, session, source): # this is the MessageWidget that __init__() would return mock_msg_widget_res = mocker.MagicMock() # mock the actual MessageWidget so we can inspect the __init__ call - mock_msg_widget = mocker.patch('securedrop_client.gui.widgets.MessageWidget', - return_value=mock_msg_widget_res) + mock_msg_widget = mocker.patch( + "securedrop_client.gui.widgets.MessageWidget", return_value=mock_msg_widget_res + ) cv.add_message(message, 0) # check that we built the widget was called with the correct args mock_msg_widget.assert_called_once_with( - message.uuid, '', mock_message_ready_signal, - mock_message_download_failed_signal, 0, False + message.uuid, + "", + mock_message_ready_signal, + mock_message_download_failed_signal, + 0, + False, ) # check that we added the correct widget to the layout cv.scroll.conversation_layout.insertWidget.assert_called_once_with( - 0, mock_msg_widget_res, alignment=Qt.AlignLeft) + 0, mock_msg_widget_res, alignment=Qt.AlignLeft + ) def test_ConversationView_on_reply_sent(mocker): @@ -3521,9 +3580,9 @@ def test_ConversationView_on_reply_sent(mocker): cv.add_reply_from_reply_box = mocker.MagicMock() assert cv.reply_flag is False - cv.on_reply_sent(source.uuid, 'abc123', 'test message') + cv.on_reply_sent(source.uuid, "abc123", "test message") - cv.add_reply_from_reply_box.assert_called_with('abc123', 'test message') + cv.add_reply_from_reply_box.assert_called_with("abc123", "test message") assert cv.reply_flag is True @@ -3537,7 +3596,7 @@ def test_ConversationView_on_reply_sent_does_not_add_message_intended_for_differ cv = ConversationView(source, controller) cv.add_reply = mocker.MagicMock() - cv.on_reply_sent('different_source_id', 'mock', 'mock') + cv.on_reply_sent("different_source_id", "mock", "mock") assert not cv.add_reply.called @@ -3555,29 +3614,37 @@ def test_ConversationView_add_reply_from_reply_box(mocker): reply_ready=reply_ready, reply_download_failed=reply_download_failed, reply_succeeded=reply_succeeded, - reply_failed=reply_failed + reply_failed=reply_failed, ) cv = ConversationView(source, controller) cv.scroll.conversation_layout = mocker.MagicMock() reply_widget_res = mocker.MagicMock() reply_widget = mocker.patch( - 'securedrop_client.gui.widgets.ReplyWidget', return_value=reply_widget_res) + "securedrop_client.gui.widgets.ReplyWidget", return_value=reply_widget_res + ) - cv.add_reply_from_reply_box('abc123', 'test message') + cv.add_reply_from_reply_box("abc123", "test message") reply_widget.assert_called_once_with( - 'abc123', 'test message', 'PENDING', reply_ready, reply_download_failed, - reply_succeeded, reply_failed, 0 + "abc123", + "test message", + "PENDING", + reply_ready, + reply_download_failed, + reply_succeeded, + reply_failed, + 0, ) cv.scroll.conversation_layout.insertWidget.assert_called_once_with( - 0, reply_widget_res, alignment=Qt.AlignRight) + 0, reply_widget_res, alignment=Qt.AlignRight + ) def test_ConversationView_add_reply(mocker, session, source): """ Adding a reply from a source results in a new ReplyWidget added to the layout. """ - source = source['source'] # grab the source from the fixture dict for simplicity + source = source["source"] # grab the source from the fixture dict for simplicity mock_reply_ready_signal = mocker.MagicMock() mock_reply_download_failed_signal = mocker.MagicMock() @@ -3588,10 +3655,10 @@ def test_ConversationView_add_reply(mocker, session, source): reply_ready=mock_reply_ready_signal, reply_download_failed=mock_reply_download_failed_signal, reply_succeeded=mock_reply_succeeded_signal, - reply_failed=mock_reply_failed_signal + reply_failed=mock_reply_failed_signal, ) - content = 'a sea, a bee' + content = "a sea, a bee" reply = factory.Reply(source=source, content=content) session.add(reply) session.commit() @@ -3601,8 +3668,9 @@ def test_ConversationView_add_reply(mocker, session, source): # this is the Reply that __init__() would return reply_widget_res = mocker.MagicMock() # mock the actual MessageWidget so we can inspect the __init__ call - mock_reply_widget = mocker.patch('securedrop_client.gui.widgets.ReplyWidget', - return_value=reply_widget_res) + mock_reply_widget = mocker.patch( + "securedrop_client.gui.widgets.ReplyWidget", return_value=reply_widget_res + ) cv.add_reply(reply, 0) @@ -3610,18 +3678,19 @@ def test_ConversationView_add_reply(mocker, session, source): mock_reply_widget.assert_called_once_with( reply.uuid, content, - 'SUCCEEDED', + "SUCCEEDED", mock_reply_ready_signal, mock_reply_download_failed_signal, mock_reply_succeeded_signal, mock_reply_failed_signal, 0, - False + False, ) # check that we added the correct widget to the layout cv.scroll.conversation_layout.insertWidget.assert_called_once_with( - 0, reply_widget_res, alignment=Qt.AlignRight) + 0, reply_widget_res, alignment=Qt.AlignRight + ) def test_ConversationView_add_reply_no_content(mocker, session, source): @@ -3630,17 +3699,19 @@ def test_ConversationView_add_reply_no_content(mocker, session, source): checks that if a `Reply` has `content = None` that a helpful message is displayed as would be the case if download/decryption never occurred or failed. """ - source = source['source'] # grab the source from the fixture dict for simplicity + source = source["source"] # grab the source from the fixture dict for simplicity mock_reply_ready_signal = mocker.MagicMock() mock_reply_download_failed_signal = mocker.MagicMock() mock_reply_succeeded_signal = mocker.MagicMock() mock_reply_failed_signal = mocker.MagicMock() - mocked_controller = mocker.MagicMock(session=session, - reply_ready=mock_reply_ready_signal, - reply_download_failed=mock_reply_download_failed_signal, - reply_succeeded=mock_reply_succeeded_signal, - reply_failed=mock_reply_failed_signal) + mocked_controller = mocker.MagicMock( + session=session, + reply_ready=mock_reply_ready_signal, + reply_download_failed=mock_reply_download_failed_signal, + reply_succeeded=mock_reply_succeeded_signal, + reply_failed=mock_reply_failed_signal, + ) reply = factory.Reply(source=source, is_decrypted=False, content=None) session.add(reply) @@ -3651,27 +3722,29 @@ def test_ConversationView_add_reply_no_content(mocker, session, source): # this is the Reply that __init__() would return reply_widget_res = mocker.MagicMock() # mock the actual MessageWidget so we can inspect the __init__ call - mock_reply_widget = mocker.patch('securedrop_client.gui.widgets.ReplyWidget', - return_value=reply_widget_res) + mock_reply_widget = mocker.patch( + "securedrop_client.gui.widgets.ReplyWidget", return_value=reply_widget_res + ) cv.add_reply(reply, 0) # check that we built the widget was called with the correct args mock_reply_widget.assert_called_once_with( reply.uuid, - '', - 'SUCCEEDED', + "", + "SUCCEEDED", mock_reply_ready_signal, mock_reply_download_failed_signal, mock_reply_succeeded_signal, mock_reply_failed_signal, 0, - False + False, ) # check that we added the correct widget to the layout cv.scroll.conversation_layout.insertWidget.assert_called_once_with( - 0, reply_widget_res, alignment=Qt.AlignRight) + 0, reply_widget_res, alignment=Qt.AlignRight + ) def test_ConversationView_add_downloaded_file(mocker, homedir, source, session): @@ -3679,7 +3752,7 @@ def test_ConversationView_add_downloaded_file(mocker, homedir, source, session): Adding a file results in a new FileWidget added to the layout with the proper QLabel. """ - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) file.is_downloaded = True session.add(file) session.commit() @@ -3687,17 +3760,17 @@ def test_ConversationView_add_downloaded_file(mocker, homedir, source, session): mock_get_file = mocker.MagicMock(return_value=file) mocked_controller = mocker.MagicMock(get_file=mock_get_file) - cv = ConversationView(source['source'], mocked_controller) + cv = ConversationView(source["source"], mocked_controller) cv.scroll.conversation_layout = mocker.MagicMock() cv.conversation_updated = mocker.MagicMock() - mock_label = mocker.patch('securedrop_client.gui.widgets.SecureQLabel') - mocker.patch('securedrop_client.gui.widgets.QHBoxLayout.addWidget') - mocker.patch('securedrop_client.gui.widgets.FileWidget.setLayout') + mock_label = mocker.patch("securedrop_client.gui.widgets.SecureQLabel") + mocker.patch("securedrop_client.gui.widgets.QHBoxLayout.addWidget") + mocker.patch("securedrop_client.gui.widgets.FileWidget.setLayout") cv.add_file(file, 0) - mock_label.assert_called_with('123B') # default factory filesize + mock_label.assert_called_with("123B") # default factory filesize assert cv.scroll.conversation_layout.insertWidget.call_count == 1 assert cv.conversation_updated.emit.call_count == 1 @@ -3710,18 +3783,18 @@ def test_ConversationView_add_not_downloaded_file(mocker, homedir, source, sessi Adding a file results in a new FileWidget added to the layout with the proper QLabel. """ - file = factory.File(source=source['source'], is_downloaded=False, is_decrypted=None, size=123) + file = factory.File(source=source["source"], is_downloaded=False, is_decrypted=None, size=123) session.add(file) session.commit() mock_get_file = mocker.MagicMock(return_value=file) mocked_controller = mocker.MagicMock(get_file=mock_get_file) - cv = ConversationView(source['source'], mocked_controller) + cv = ConversationView(source["source"], mocked_controller) cv.scroll.conversation_layout = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.QHBoxLayout.addWidget') - mocker.patch('securedrop_client.gui.widgets.FileWidget.setLayout') + mocker.patch("securedrop_client.gui.widgets.QHBoxLayout.addWidget") + mocker.patch("securedrop_client.gui.widgets.FileWidget.setLayout") cv.add_file(file, 0) assert cv.scroll.conversation_layout.insertWidget.call_count == 1 @@ -3732,11 +3805,11 @@ def test_ConversationView_add_not_downloaded_file(mocker, homedir, source, sessi def test_DeleteSourceMessageBox_init(mocker, source): mock_controller = mocker.MagicMock() - DeleteSourceMessageBox(source['source'], mock_controller) + DeleteSourceMessageBox(source["source"], mock_controller) def test_DeleteSourceMessage_launch_when_user_chooses_cancel(mocker, source): - source = source['source'] # to get the Source object + source = source["source"] # to get the Source object mock_message_box_question = mocker.MagicMock(QMessageBox.question) mock_message_box_question.return_value = QMessageBox.Cancel @@ -3745,8 +3818,7 @@ def test_DeleteSourceMessage_launch_when_user_chooses_cancel(mocker, source): delete_source_message_box = DeleteSourceMessageBox(source, mock_controller) mocker.patch( - "securedrop_client.gui.widgets.QMessageBox.question", - mock_message_box_question, + "securedrop_client.gui.widgets.QMessageBox.question", mock_message_box_question, ) delete_source_message_box.launch() @@ -3754,7 +3826,7 @@ def test_DeleteSourceMessage_launch_when_user_chooses_cancel(mocker, source): def test_DeleteSourceMssageBox_launch_when_user_chooses_yes(mocker, source, session): - source = source['source'] # to get the Source object + source = source["source"] # to get the Source object file_ = factory.File(source=source) session.add(file_) message = factory.Message(source=source) @@ -3772,8 +3844,7 @@ def test_DeleteSourceMssageBox_launch_when_user_chooses_yes(mocker, source, sess delete_source_message_box = DeleteSourceMessageBox(source, mock_controller) mocker.patch( - "securedrop_client.gui.widgets.QMessageBox.question", - mock_message_box_question, + "securedrop_client.gui.widgets.QMessageBox.question", mock_message_box_question, ) delete_source_message_box.launch() @@ -3788,16 +3859,12 @@ def test_DeleteSourceMssageBox_launch_when_user_chooses_yes(mocker, source, sess "through the log-in tied to this account." ).format(designation=source.journalist_designation, files=1, replies=1, messages=2) mock_message_box_question.assert_called_once_with( - None, - "", - message, - QMessageBox.Cancel | QMessageBox.Yes, - QMessageBox.Cancel + None, "", message, QMessageBox.Cancel | QMessageBox.Yes, QMessageBox.Cancel ) def test_DeleteSourceMessageBox_construct_message(mocker, source, session): - source = source['source'] # to get the Source object + source = source["source"] # to get the Source object file_ = factory.File(source=source) session.add(file_) message = factory.Message(source=source) @@ -3828,11 +3895,7 @@ def test_DeleteSourceMessageBox_construct_message(mocker, source, session): def test_DeleteSourceAction_init(mocker): mock_controller = mocker.MagicMock() mock_source = mocker.MagicMock() - DeleteSourceAction( - mock_source, - None, - mock_controller - ) + DeleteSourceAction(mock_source, None, mock_controller) def test_PasswordEdit(mocker): @@ -3849,19 +3912,12 @@ def test_DeleteSourceAction_trigger(mocker): mock_source = mocker.MagicMock() mock_delete_source_message_box_obj = mocker.MagicMock() mock_delete_source_message_box = mocker.MagicMock() - mock_delete_source_message_box.return_value = ( - mock_delete_source_message_box_obj - ) + mock_delete_source_message_box.return_value = mock_delete_source_message_box_obj with mocker.patch( - 'securedrop_client.gui.widgets.DeleteSourceMessageBox', - mock_delete_source_message_box + "securedrop_client.gui.widgets.DeleteSourceMessageBox", mock_delete_source_message_box ): - delete_source_action = DeleteSourceAction( - mock_source, - None, - mock_controller - ) + delete_source_action = DeleteSourceAction(mock_source, None, mock_controller) delete_source_action.trigger() mock_delete_source_message_box_obj.launch.assert_called_once_with() @@ -3872,13 +3928,10 @@ def test_DeleteSource_from_source_menu_when_user_is_loggedout(mocker): mock_controller.api = None mock_delete_source_message_box_obj = mocker.MagicMock() mock_delete_source_message_box = mocker.MagicMock() - mock_delete_source_message_box.return_value = ( - mock_delete_source_message_box_obj - ) + mock_delete_source_message_box.return_value = mock_delete_source_message_box_obj with mocker.patch( - 'securedrop_client.gui.widgets.DeleteSourceMessageBox', - mock_delete_source_message_box + "securedrop_client.gui.widgets.DeleteSourceMessageBox", mock_delete_source_message_box ): source_menu = SourceMenu(mock_source, mock_controller) source_menu.actions()[0].trigger() @@ -3886,19 +3939,16 @@ def test_DeleteSource_from_source_menu_when_user_is_loggedout(mocker): def test_DeleteSource_from_source_widget_when_user_is_loggedout(mocker): - mock_source = mocker.MagicMock(journalist_designation='mock') + mock_source = mocker.MagicMock(journalist_designation="mock") mock_controller = mocker.MagicMock() mock_controller.api = None mock_event = mocker.MagicMock() mock_delete_source_message_box_obj = mocker.MagicMock() mock_delete_source_message_box = mocker.MagicMock() - mock_delete_source_message_box.return_value = ( - mock_delete_source_message_box_obj - ) + mock_delete_source_message_box.return_value = mock_delete_source_message_box_obj with mocker.patch( - 'securedrop_client.gui.widgets.DeleteSourceMessageBox', - mock_delete_source_message_box + "securedrop_client.gui.widgets.DeleteSourceMessageBox", mock_delete_source_message_box ): source_widget = SourceWidget(mock_controller, mock_source) source_widget.delete_source(mock_event) @@ -3947,25 +3997,25 @@ def test_ReplyBoxWidget_send_reply(mocker): Ensure sending a reply from the reply box emits signal, clears text box, and sends the reply details to the controller. """ - source = factory.Source(uuid='abc123') - reply_uuid = '456xyz' - mocker.patch('securedrop_client.gui.widgets.uuid4', return_value=reply_uuid) + source = factory.Source(uuid="abc123") + reply_uuid = "456xyz" + mocker.patch("securedrop_client.gui.widgets.uuid4", return_value=reply_uuid) controller = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.SourceProfileShortWidget') - mocker.patch('securedrop_client.gui.widgets.QVBoxLayout.addWidget') + mocker.patch("securedrop_client.gui.widgets.SourceProfileShortWidget") + mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") scw = SourceConversationWrapper(source, controller) on_reply_sent_fn = mocker.MagicMock() scw.conversation_view.on_reply_sent = on_reply_sent_fn scw.reply_box.reply_sent = mocker.MagicMock() scw.reply_box.text_edit = ReplyTextEdit(source, controller) scw.reply_box.text_edit.setText = mocker.MagicMock() - scw.reply_box.text_edit.setPlainText('Alles für Alle') + scw.reply_box.text_edit.setPlainText("Alles für Alle") scw.reply_box.send_reply() - scw.reply_box.reply_sent.emit.assert_called_once_with('abc123', '456xyz', 'Alles für Alle') - scw.reply_box.text_edit.setText.assert_called_once_with('') - controller.send_reply.assert_called_once_with('abc123', '456xyz', 'Alles für Alle') + scw.reply_box.reply_sent.emit.assert_called_once_with("abc123", "456xyz", "Alles für Alle") + scw.reply_box.text_edit.setText.assert_called_once_with("") + controller.send_reply.assert_called_once_with("abc123", "456xyz", "Alles für Alle") def test_ReplyBoxWidget_send_reply_calls_setText_after_send(mocker): @@ -3977,12 +4027,12 @@ def test_ReplyBoxWidget_send_reply_calls_setText_after_send(mocker): controller = mocker.MagicMock() rb = ReplyBoxWidget(source, controller) rb.text_edit = ReplyTextEdit(source, controller) - setText = mocker.patch.object(rb.text_edit, 'setText') - rb.text_edit.setPlainText('Alles für Alle') + setText = mocker.patch.object(rb.text_edit, "setText") + rb.text_edit.setPlainText("Alles für Alle") rb.send_reply() - setText.assert_called_once_with('') + setText.assert_called_once_with("") def test_ReplyBoxWidget_send_reply_does_not_send_empty_string(mocker): @@ -4000,7 +4050,7 @@ def test_ReplyBoxWidget_send_reply_does_not_send_empty_string(mocker): assert not controller.send_reply.called # Also check that we don't send blank space - rb.text_edit.setText(' \n\n ') + rb.text_edit.setText(" \n\n ") rb.send_reply() @@ -4026,18 +4076,16 @@ def test_ReplyBoxWidget_on_synced(mocker): def test_ReplyBoxWidget_on_sync_source_deleted(mocker, source): - s = source['source'] + s = source["source"] controller = mocker.MagicMock() rb = ReplyBoxWidget(s, controller) - error_logger = mocker.patch('securedrop_client.gui.widgets.logger.debug') + error_logger = mocker.patch("securedrop_client.gui.widgets.logger.debug") def pretend_source_was_deleted(self): - raise sqlalchemy.orm.exc.ObjectDeletedError( - attributes.instance_state(s), None - ) + raise sqlalchemy.orm.exc.ObjectDeletedError(attributes.instance_state(s), None) - with patch.object(ReplyBoxWidget, 'update_authentication_state') as uas: + with patch.object(ReplyBoxWidget, "update_authentication_state") as uas: uas.side_effect = pretend_source_was_deleted rb._on_synced("syncing") error_logger.assert_called_once_with( @@ -4050,16 +4098,18 @@ def test_ReplyWidget_success_failure_slots(mocker): mock_download_failed_signal = mocker.Mock() mock_success_signal = mocker.Mock() mock_failure_signal = mocker.Mock() - msg_id = 'abc123' + msg_id = "abc123" - widget = ReplyWidget(msg_id, - 'lol', - 'PENDING', - mock_update_signal, - mock_download_failed_signal, - mock_success_signal, - mock_failure_signal, - 0) + widget = ReplyWidget( + msg_id, + "lol", + "PENDING", + mock_update_signal, + mock_download_failed_signal, + mock_success_signal, + mock_failure_signal, + 0, + ) # ensure we have connected the slots mock_success_signal.connect.assert_called_once_with(widget._on_reply_success) @@ -4068,9 +4118,9 @@ def test_ReplyWidget_success_failure_slots(mocker): assert mock_download_failed_signal.connect.called # check the success slog - widget._on_reply_success('mock_source_id', msg_id + "x", 'lol') + widget._on_reply_success("mock_source_id", msg_id + "x", "lol") assert widget.error.isHidden() - widget._on_reply_success('mock_source_id', msg_id, 'lol') + widget._on_reply_success("mock_source_id", msg_id, "lol") assert widget.error.isHidden() # check the failure slot where message id does not match @@ -4096,18 +4146,16 @@ def test_ReplyBoxWidget__on_authentication_changed(mocker, homedir): def test_ReplyBoxWidget_on_authentication_changed_source_deleted(mocker, source): - s = source['source'] + s = source["source"] controller = mocker.MagicMock() rb = ReplyBoxWidget(s, controller) - error_logger = mocker.patch('securedrop_client.gui.widgets.logger.debug') + error_logger = mocker.patch("securedrop_client.gui.widgets.logger.debug") def pretend_source_was_deleted(self): - raise sqlalchemy.orm.exc.ObjectDeletedError( - attributes.instance_state(s), None - ) + raise sqlalchemy.orm.exc.ObjectDeletedError(attributes.instance_state(s), None) - with patch.object(ReplyBoxWidget, 'update_authentication_state') as uas: + with patch.object(ReplyBoxWidget, "update_authentication_state") as uas: uas.side_effect = pretend_source_was_deleted rb._on_authentication_changed(True) error_logger.assert_called_once_with( @@ -4139,7 +4187,8 @@ def test_ReplyBoxWidget_auth_signals(mocker, homedir): controller.is_authenticated = False _on_authentication_changed_fn = mocker.patch.object( - ReplyBoxWidget, '_on_authentication_changed') + ReplyBoxWidget, "_on_authentication_changed" + ) ReplyBoxWidget(factory.Source(), controller) @@ -4156,7 +4205,7 @@ def test_ReplyBoxWidget_enable(mocker): rb.set_logged_in() - assert rb.text_edit.toPlainText() == '' + assert rb.text_edit.toPlainText() == "" rb.text_edit.set_logged_in.assert_called_once_with() rb.send_button.show.assert_called_once_with() @@ -4171,7 +4220,7 @@ def test_ReplyBoxWidget_disable(mocker): rb.set_logged_out() - assert rb.text_edit.toPlainText() == '' + assert rb.text_edit.toPlainText() == "" rb.text_edit.set_logged_out.assert_called_once_with() rb.send_button.hide.assert_called_once_with() @@ -4181,9 +4230,9 @@ def test_ReplyBoxWidget_enable_after_source_gets_key(mocker, session, session_ma Test that it's enabled when a source that lacked a key now has one. """ - with mocker.patch('sdclientapi.API'): + with mocker.patch("sdclientapi.API"): mock_gui = mocker.MagicMock() - controller = logic.Controller('http://localhost', mock_gui, session_maker, homedir) + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir) controller.is_authenticated = True # create source without key or fingerprint @@ -4227,11 +4276,11 @@ def test_ReplyTextEdit_focus_change_no_text(mocker): rt.focusInEvent(focus_in_event) assert rt.placeholder.isHidden() - assert rt.toPlainText() == '' + assert rt.toPlainText() == "" rt.focusOutEvent(focus_out_event) assert not rt.placeholder.isHidden() - assert rt.toPlainText() == '' + assert rt.toPlainText() == "" def test_ReplyTextEdit_focus_change_with_text_typed(mocker): @@ -4241,7 +4290,7 @@ def test_ReplyTextEdit_focus_change_with_text_typed(mocker): """ controller = mocker.MagicMock() rt = ReplyTextEdit(factory.Source(), controller) - reply_text = 'mocked reply text' + reply_text = "mocked reply text" rt.setText(reply_text) focus_in_event = QFocusEvent(QEvent.FocusIn) @@ -4262,12 +4311,12 @@ def test_ReplyTextEdit_setText(mocker): setPlainText method is called (to ensure cursor is hidden). """ rt = ReplyTextEdit(factory.Source(), mocker.MagicMock()) - mocker.patch('securedrop_client.gui.widgets.QPlainTextEdit.setPlainText') + mocker.patch("securedrop_client.gui.widgets.QPlainTextEdit.setPlainText") - rt.setText('mocked reply text') + rt.setText("mocked reply text") assert rt.placeholder.isHidden() - rt.setPlainText.assert_called_once_with('mocked reply text') + rt.setPlainText.assert_called_once_with("mocked reply text") def test_ReplyTextEdit_setText_empty_string(mocker): @@ -4276,12 +4325,12 @@ def test_ReplyTextEdit_setText_empty_string(mocker): method is called (to ensure cursor is hidden). """ rt = ReplyTextEdit(factory.Source(), mocker.MagicMock()) - mocker.patch('securedrop_client.gui.widgets.QPlainTextEdit.setPlainText') + mocker.patch("securedrop_client.gui.widgets.QPlainTextEdit.setPlainText") - rt.setText('') + rt.setText("") assert not rt.placeholder.isHidden() - rt.setPlainText.assert_called_once_with('') + rt.setPlainText.assert_called_once_with("") def test_ReplyTextEdit_set_logged_out(mocker): @@ -4297,8 +4346,8 @@ def test_ReplyTextEdit_set_logged_out(mocker): sign_in = rt.placeholder.signed_out.layout().itemAt(0).widget() to_compose_reply = rt.placeholder.signed_out.layout().itemAt(1).widget() - assert 'Sign in' == sign_in.text() - assert ' to compose or send a reply' in to_compose_reply.text() + assert "Sign in" == sign_in.text() + assert " to compose or send a reply" in to_compose_reply.text() def test_ReplyTextEdit_set_logged_in(mocker): @@ -4313,7 +4362,7 @@ def test_ReplyTextEdit_set_logged_in(mocker): compose_a_reply_to = rt.placeholder.signed_in.layout().itemAt(0).widget() source_name = rt.placeholder.signed_in.layout().itemAt(1).widget() - assert 'Compose a reply to ' == compose_a_reply_to.text() + assert "Compose a reply to " == compose_a_reply_to.text() assert source.journalist_designation == source_name.text() @@ -4332,8 +4381,8 @@ def test_ReplyBox_set_logged_in_no_public_key(mocker): awaiting_key = rb.text_edit.placeholder.signed_in_no_key.layout().itemAt(0).widget() from_server = rb.text_edit.placeholder.signed_in_no_key.layout().itemAt(1).widget() - assert 'Awaiting encryption key' == awaiting_key.text() - assert ' from server to enable replies' == from_server.text() + assert "Awaiting encryption key" == awaiting_key.text() + assert " from server to enable replies" == from_server.text() # Both the reply box and the text editor must be disabled for the widget # to be rendered correctly. @@ -4350,11 +4399,11 @@ def test_update_conversation_maintains_old_items(mocker, session): session.add(source) session.commit() - file_ = factory.File(filename='1-source-doc.gpg', source=source) + file_ = factory.File(filename="1-source-doc.gpg", source=source) session.add(file_) - message = factory.Message(filename='2-source-msg.gpg', source=source) + message = factory.Message(filename="2-source-msg.gpg", source=source) session.add(message) - reply = factory.Reply(filename='3-source-reply.gpg', source=source) + reply = factory.Reply(filename="3-source-reply.gpg", source=source) session.add(reply) session.commit() @@ -4380,9 +4429,9 @@ def test_update_conversation_does_not_remove_pending_draft_items(mocker, session session.add(send_status) session.commit() - file_ = factory.File(filename='1-source-doc.gpg', source=source) + file_ = factory.File(filename="1-source-doc.gpg", source=source) session.add(file_) - message = factory.Message(filename='2-source-msg.gpg', source=source) + message = factory.Message(filename="2-source-msg.gpg", source=source) session.add(message) draft_reply = factory.DraftReply(source=source, send_status=send_status) session.add(draft_reply) @@ -4395,7 +4444,7 @@ def test_update_conversation_does_not_remove_pending_draft_items(mocker, session assert cv.scroll.conversation_layout.count() == 3 # precondition with draft # add the new message and persist - new_message = factory.Message(filename='4-source-msg.gpg', source=source) + new_message = factory.Message(filename="4-source-msg.gpg", source=source) session.add(new_message) session.commit() @@ -4415,9 +4464,9 @@ def test_update_conversation_does_remove_successful_draft_items(mocker, session) session.add(send_status) session.commit() - file_ = factory.File(filename='1-source-doc.gpg', source=source) + file_ = factory.File(filename="1-source-doc.gpg", source=source) session.add(file_) - message = factory.Message(filename='2-source-msg.gpg', source=source) + message = factory.Message(filename="2-source-msg.gpg", source=source) session.add(message) draft_reply = factory.DraftReply(source=source, send_status=send_status) session.add(draft_reply) @@ -4430,7 +4479,7 @@ def test_update_conversation_does_remove_successful_draft_items(mocker, session) assert cv.scroll.conversation_layout.count() == 3 # precondition with draft # add the new message and persist - new_message = factory.Message(filename='4-source-msg.gpg', source=source) + new_message = factory.Message(filename="4-source-msg.gpg", source=source) session.add(new_message) session.commit() @@ -4454,9 +4503,9 @@ def test_update_conversation_keeps_failed_draft_items(mocker, session): session.add(send_status) session.commit() - file_ = factory.File(filename='1-source-doc.gpg', source=source) + file_ = factory.File(filename="1-source-doc.gpg", source=source) session.add(file_) - message = factory.Message(filename='2-source-msg.gpg', source=source) + message = factory.Message(filename="2-source-msg.gpg", source=source) session.add(message) draft_reply = factory.DraftReply(source=source, send_status=send_status) session.add(draft_reply) @@ -4469,7 +4518,7 @@ def test_update_conversation_keeps_failed_draft_items(mocker, session): assert cv.scroll.conversation_layout.count() == 3 # precondition with draft # add the new message and persist - new_message = factory.Message(filename='4-source-msg.gpg', source=source) + new_message = factory.Message(filename="4-source-msg.gpg", source=source) session.add(new_message) session.commit() @@ -4486,11 +4535,11 @@ def test_update_conversation_adds_new_items(mocker, session): session.add(source) session.commit() - file_ = factory.File(filename='1-source-doc.gpg', source=source) + file_ = factory.File(filename="1-source-doc.gpg", source=source) session.add(file_) - message = factory.Message(filename='2-source-msg.gpg', source=source) + message = factory.Message(filename="2-source-msg.gpg", source=source) session.add(message) - reply = factory.Reply(filename='3-source-reply.gpg', source=source) + reply = factory.Reply(filename="3-source-reply.gpg", source=source) session.add(reply) session.commit() @@ -4501,7 +4550,7 @@ def test_update_conversation_adds_new_items(mocker, session): assert cv.scroll.conversation_layout.count() == 3 # precondition # add the new message and persist - new_message = factory.Message(filename='4-source-msg.gpg', source=source) + new_message = factory.Message(filename="4-source-msg.gpg", source=source) session.add(new_message) session.commit() @@ -4517,11 +4566,11 @@ def test_update_conversation_position_updates(mocker, session): session.add(source) session.commit() - file_ = factory.File(filename='1-source-doc.gpg', source=source) + file_ = factory.File(filename="1-source-doc.gpg", source=source) session.add(file_) - message = factory.Message(filename='2-source-msg.gpg', source=source) + message = factory.Message(filename="2-source-msg.gpg", source=source) session.add(message) - reply = factory.Reply(filename='3-source-reply.gpg', source=source) + reply = factory.Reply(filename="3-source-reply.gpg", source=source) session.add(reply) session.commit() @@ -4536,7 +4585,7 @@ def test_update_conversation_position_updates(mocker, session): reply_widget.index = 1 # add the new message and persist - new_message = factory.Message(filename='4-source-msg.gpg', source=source) + new_message = factory.Message(filename="4-source-msg.gpg", source=source) session.add(new_message) session.commit() @@ -4557,7 +4606,7 @@ def test_update_conversation_content_updates(mocker, session): session.add(source) session.commit() - message = factory.Message(filename='2-source-msg.gpg', source=source, content=None) + message = factory.Message(filename="2-source-msg.gpg", source=source, content=None) session.add(message) session.commit() @@ -4570,21 +4619,22 @@ def test_update_conversation_content_updates(mocker, session): mock_msg_widget_res = mocker.MagicMock() # mock MessageWidget so we can inspect the __init__ call to see what content # is in the widget. - mock_msg_widget = mocker.patch('securedrop_client.gui.widgets.MessageWidget', - return_value=mock_msg_widget_res) + mock_msg_widget = mocker.patch( + "securedrop_client.gui.widgets.MessageWidget", return_value=mock_msg_widget_res + ) # First call of update_conversation: with null content cv.update_conversation(cv.source.collection) # Since the content was None, we should have created the widget # with the default message (which is the second call_arg). - assert mock_msg_widget.call_args[0][1] == '' + assert mock_msg_widget.call_args[0][1] == "" # Meanwhile, in another session, we add content to the database for that same message. engine = session.get_bind() second_session = scoped_session(sessionmaker(bind=engine)) message = second_session.query(db.Message).one() - expected_content = 'now there is content here!' + expected_content = "now there is content here!" message.content = expected_content second_session.add(message) second_session.commit() @@ -4609,4 +4659,5 @@ def test_SourceProfileShortWidget_update_timestamp(mocker): spsw.updated = mocker.MagicMock() spsw.update_timestamp() spsw.updated.setText.assert_called_once_with( - arrow.get(mock_source.last_updated).format('DD MMM')) + arrow.get(mock_source.last_updated).format("DD MMM") + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 283453159..ad953000c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,21 +1,20 @@ import pytest from PyQt5.QtWidgets import QApplication -from securedrop_client.logic import Controller from securedrop_client.gui.main import Window from securedrop_client.gui.widgets import ExportDialog, ModalDialog, PrintDialog - +from securedrop_client.logic import Controller from tests import factory -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def main_window(mocker, homedir): # Setup app = QApplication([]) gui = Window() app.setActiveWindow(gui) gui.show() - controller = Controller('http://localhost', gui, mocker.MagicMock(), homedir, proxy=False) + controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) controller.qubes = False gui.setup(controller) @@ -25,13 +24,17 @@ def main_window(mocker, homedir): source_list.update([source]) # Create a file widget, message widget, and reply widget - mocker.patch('securedrop_client.gui.widgets.humanize_filesize', return_value='100') + mocker.patch("securedrop_client.gui.widgets.humanize_filesize", return_value="100") mocker.patch( - 'securedrop_client.gui.SecureQLabel.get_elided_text', return_value='1-yellow-doc.gz.gpg') - source.collection.append([ - factory.File(source=source, filename='1-yellow-doc.gz.gpg'), - factory.Message(source=source, filename='2-yellow-msg.gpg'), - factory.Reply(source=source, filename='3-yellow-reply.gpg')]) + "securedrop_client.gui.SecureQLabel.get_elided_text", return_value="1-yellow-doc.gz.gpg" + ) + source.collection.append( + [ + factory.File(source=source, filename="1-yellow-doc.gz.gpg"), + factory.Message(source=source, filename="2-yellow-msg.gpg"), + factory.Reply(source=source, filename="3-yellow-reply.gpg"), + ] + ) source_list.setCurrentItem(source_list.item(0)) gui.main_view.on_source_changed() @@ -42,14 +45,14 @@ def main_window(mocker, homedir): app.exit() -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def main_window_no_key(mocker, homedir): # Setup app = QApplication([]) gui = Window() app.setActiveWindow(gui) gui.show() - controller = Controller('http://localhost', gui, mocker.MagicMock(), homedir, proxy=False) + controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) controller.qubes = False gui.setup(controller) @@ -59,13 +62,17 @@ def main_window_no_key(mocker, homedir): source_list.update([source]) # Create a file widget, message widget, and reply widget - mocker.patch('securedrop_client.gui.widgets.humanize_filesize', return_value='100') + mocker.patch("securedrop_client.gui.widgets.humanize_filesize", return_value="100") mocker.patch( - 'securedrop_client.gui.SecureQLabel.get_elided_text', return_value='1-yellow-doc.gz.gpg') - source.collection.append([ - factory.File(source=source, filename='1-yellow-doc.gz.gpg'), - factory.Message(source=source, filename='2-yellow-msg.gpg'), - factory.Reply(source=source, filename='3-yellow-reply.gpg')]) + "securedrop_client.gui.SecureQLabel.get_elided_text", return_value="1-yellow-doc.gz.gpg" + ) + source.collection.append( + [ + factory.File(source=source, filename="1-yellow-doc.gz.gpg"), + factory.Message(source=source, filename="2-yellow-msg.gpg"), + factory.Reply(source=source, filename="3-yellow-reply.gpg"), + ] + ) source_list.setCurrentItem(source_list.item(0)) gui.main_view.on_source_changed() @@ -76,12 +83,12 @@ def main_window_no_key(mocker, homedir): app.exit() -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def modal_dialog(mocker, homedir): app = QApplication([]) gui = Window() gui.show() - controller = Controller('http://localhost', gui, mocker.MagicMock(), homedir, proxy=False) + controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) controller.qubes = False gui.setup(controller) gui.login_dialog.close() @@ -94,17 +101,17 @@ def modal_dialog(mocker, homedir): app.exit() -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def print_dialog(mocker, homedir): app = QApplication([]) gui = Window() gui.show() - controller = Controller('http://localhost', gui, mocker.MagicMock(), homedir, proxy=False) + controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) controller.qubes = False gui.setup(controller) gui.login_dialog.close() app.setActiveWindow(gui) - dialog = PrintDialog(controller, 'file_uuid', 'file_name') + dialog = PrintDialog(controller, "file_uuid", "file_name") yield dialog @@ -112,17 +119,17 @@ def print_dialog(mocker, homedir): app.exit() -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def export_dialog(mocker, homedir): app = QApplication([]) gui = Window() gui.show() - controller = Controller('http://localhost', gui, mocker.MagicMock(), homedir, proxy=False) + controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) controller.qubes = False gui.setup(controller) gui.login_dialog.close() app.setActiveWindow(gui) - dialog = ExportDialog(controller, 'file_uuid', 'file_name') + dialog = ExportDialog(controller, "file_uuid", "file_name") dialog.show() yield dialog diff --git a/tests/integration/test_placeholder.py b/tests/integration/test_placeholder.py index 108e2a801..d7b5962a9 100644 --- a/tests/integration/test_placeholder.py +++ b/tests/integration/test_placeholder.py @@ -7,30 +7,30 @@ def test_styles_for_placeholder(main_window): reply_text_edit = reply_box.text_edit sign_in = reply_text_edit.placeholder.signed_out.layout().itemAt(0).widget() - assert 'Montserrat' == sign_in.font().family() + assert "Montserrat" == sign_in.font().family() assert QFont.Bold == sign_in.font().weight() assert 18 == sign_in.font().pixelSize() - assert '#2a319d' == sign_in.palette().color(QPalette.Foreground).name() + assert "#2a319d" == sign_in.palette().color(QPalette.Foreground).name() to_compose_reply = reply_text_edit.placeholder.signed_out.layout().itemAt(1).widget() - assert 'Montserrat' == to_compose_reply.font().family() + assert "Montserrat" == to_compose_reply.font().family() assert QFont.Normal == to_compose_reply.font().weight() assert 18 == to_compose_reply.font().pixelSize() - assert '#404040' == to_compose_reply.palette().color(QPalette.Foreground).name() + assert "#404040" == to_compose_reply.palette().color(QPalette.Foreground).name() reply_box.set_logged_in() compose_a_reply_to = reply_text_edit.placeholder.signed_in.layout().itemAt(0).widget() - assert 'Montserrat' == compose_a_reply_to.font().family() + assert "Montserrat" == compose_a_reply_to.font().family() assert QFont.Normal == compose_a_reply_to.font().weight() assert 18 == compose_a_reply_to.font().pixelSize() - assert '#404040' == compose_a_reply_to.palette().color(QPalette.Foreground).name() + assert "#404040" == compose_a_reply_to.palette().color(QPalette.Foreground).name() source_name = reply_text_edit.placeholder.signed_in.layout().itemAt(1).widget() - assert 'Montserrat' == source_name.font().family() + assert "Montserrat" == source_name.font().family() assert QFont.Bold == source_name.font().weight() assert 18 == source_name.font().pixelSize() - assert '#2a319d' == source_name.palette().color(QPalette.Foreground).name() + assert "#2a319d" == source_name.palette().color(QPalette.Foreground).name() def test_styles_for_placeholder_no_key(main_window_no_key): @@ -41,13 +41,13 @@ def test_styles_for_placeholder_no_key(main_window_no_key): reply_box.set_logged_in() awaiting_key = reply_text_edit.placeholder.signed_in_no_key.layout().itemAt(0).widget() - assert 'Montserrat' == awaiting_key.font().family() + assert "Montserrat" == awaiting_key.font().family() assert QFont.Bold == awaiting_key.font().weight() assert 18 == awaiting_key.font().pixelSize() - assert '#2a319d' == awaiting_key.palette().color(QPalette.Foreground).name() + assert "#2a319d" == awaiting_key.palette().color(QPalette.Foreground).name() from_server = reply_text_edit.placeholder.signed_in_no_key.layout().itemAt(1).widget() - assert 'Montserrat' == from_server.font().family() + assert "Montserrat" == from_server.font().family() assert QFont.Normal == from_server.font().weight() assert 18 == from_server.font().pixelSize() - assert '#404040' == from_server.palette().color(QPalette.Foreground).name() + assert "#404040" == from_server.palette().color(QPalette.Foreground).name() diff --git a/tests/integration/test_styles_file_download_button.py b/tests/integration/test_styles_file_download_button.py index 619d0bafd..f6d172935 100644 --- a/tests/integration/test_styles_file_download_button.py +++ b/tests/integration/test_styles_file_download_button.py @@ -10,23 +10,23 @@ def test_styles(mocker, main_window): file_widget = conversation_scrollarea.widget().layout().itemAt(0).widget() download_button = file_widget.download_button - expected_image = load_icon('download_file.svg').pixmap(20, 20).toImage() + expected_image = load_icon("download_file.svg").pixmap(20, 20).toImage() assert download_button.icon().pixmap(20, 20).toImage() == expected_image - assert 'Source Sans Pro' == download_button.font().family() + assert "Source Sans Pro" == download_button.font().family() assert QFont.Bold == download_button.font().weight() assert 13 == download_button.font().pixelSize() - assert '#2a319d' == download_button.palette().color(QPalette.Foreground).name() + assert "#2a319d" == download_button.palette().color(QPalette.Foreground).name() # assert 'border: none;' for download_button file_widget.eventFilter(download_button, QEvent(QEvent.HoverEnter)) - expected_image = load_icon('download_file_hover.svg').pixmap(20, 20).toImage() + expected_image = load_icon("download_file_hover.svg").pixmap(20, 20).toImage() assert download_button.icon().pixmap(20, 20).toImage() == expected_image # assert '#05a6fe' == download_button.palette().color(QPalette.Foreground).name() file_widget.eventFilter(download_button, QEvent(QEvent.HoverLeave)) - expected_image = load_icon('download_file.svg').pixmap(20, 20).toImage() + expected_image = load_icon("download_file.svg").pixmap(20, 20).toImage() assert download_button.icon().pixmap(20, 20).toImage() == expected_image - assert '#2a319d' == download_button.palette().color(QPalette.Foreground).name() + assert "#2a319d" == download_button.palette().color(QPalette.Foreground).name() def test_styles_animated(mocker, main_window): @@ -37,20 +37,20 @@ def test_styles_animated(mocker, main_window): file_widget.start_button_animation() - expected_image = load_icon('download_file.gif').pixmap(20, 20).toImage() + expected_image = load_icon("download_file.gif").pixmap(20, 20).toImage() assert download_button.icon().pixmap(20, 20).toImage() == expected_image - assert 'Source Sans Pro' == download_button.font().family() + assert "Source Sans Pro" == download_button.font().family() assert QFont.Bold == download_button.font().weight() assert 13 == download_button.font().pixelSize() - assert '#05a6fe' == download_button.palette().color(QPalette.Foreground).name() + assert "#05a6fe" == download_button.palette().color(QPalette.Foreground).name() # assert 'border: none;' for download_button file_widget.eventFilter(download_button, QEvent(QEvent.HoverEnter)) - expected_image = load_icon('download_file.gif').pixmap(20, 20).toImage() + expected_image = load_icon("download_file.gif").pixmap(20, 20).toImage() assert download_button.icon().pixmap(20, 20).toImage() == expected_image - assert '#05a6fe' == download_button.palette().color(QPalette.Foreground).name() + assert "#05a6fe" == download_button.palette().color(QPalette.Foreground).name() file_widget.eventFilter(download_button, QEvent(QEvent.HoverLeave)) - expected_image = load_icon('download_file.gif').pixmap(20, 20).toImage() + expected_image = load_icon("download_file.gif").pixmap(20, 20).toImage() assert download_button.icon().pixmap(20, 20).toImage() == expected_image - assert '#05a6fe' == download_button.palette().color(QPalette.Foreground).name() + assert "#05a6fe" == download_button.palette().color(QPalette.Foreground).name() diff --git a/tests/integration/test_styles_modal_dialog_button.py b/tests/integration/test_styles_modal_dialog_button.py index 628582186..269c87910 100644 --- a/tests/integration/test_styles_modal_dialog_button.py +++ b/tests/integration/test_styles_modal_dialog_button.py @@ -4,18 +4,18 @@ def test_styles(modal_dialog): continue_button = modal_dialog.continue_button - assert '#ffffff' == continue_button.palette().color(QPalette.Foreground).name() - assert '#2a319d' == continue_button.palette().color(QPalette.Background).name() + assert "#ffffff" == continue_button.palette().color(QPalette.Foreground).name() + assert "#2a319d" == continue_button.palette().color(QPalette.Background).name() continue_button.setEnabled(False) - assert '#e1e2f1' == continue_button.palette().color(QPalette.Foreground).name() - assert '#c2c4e3' == continue_button.palette().color(QPalette.Background).name() + assert "#e1e2f1" == continue_button.palette().color(QPalette.Foreground).name() + assert "#c2c4e3" == continue_button.palette().color(QPalette.Background).name() modal_dialog.start_animate_activestate() - assert '#ffffff' == continue_button.palette().color(QPalette.Foreground).name() - assert '#f1f1f6' == continue_button.palette().color(QPalette.Background).name() + assert "#ffffff" == continue_button.palette().color(QPalette.Foreground).name() + assert "#f1f1f6" == continue_button.palette().color(QPalette.Background).name() # assert border: 2px solid #f1f1f6; # assert (12, 0, 0, 0) == continue_button.getContentsMargins() # assert 40 == continue_button.height() diff --git a/tests/integration/test_styles_modal_dialog_error_details.py b/tests/integration/test_styles_modal_dialog_error_details.py index a029fefe1..c79c3346e 100644 --- a/tests/integration/test_styles_modal_dialog_error_details.py +++ b/tests/integration/test_styles_modal_dialog_error_details.py @@ -5,13 +5,13 @@ def test_styles(modal_dialog): error_details = modal_dialog.error_details assert (36, 0, 40, 0) == error_details.getContentsMargins() - assert '#ff0064' == error_details.palette().color(QPalette.Foreground).name() - assert 'Montserrat' == error_details.font().family() + assert "#ff0064" == error_details.palette().color(QPalette.Foreground).name() + assert "Montserrat" == error_details.font().family() assert 16 == error_details.font().pixelSize() modal_dialog.start_animate_activestate() assert (36, 0, 40, 0) == error_details.getContentsMargins() - assert '#ff66c4' == error_details.palette().color(QPalette.Foreground).name() - assert 'Montserrat' == error_details.font().family() + assert "#ff66c4" == error_details.palette().color(QPalette.Foreground).name() + assert "Montserrat" == error_details.font().family() assert 16 == error_details.font().pixelSize() diff --git a/tests/integration/test_styles_reply_message.py b/tests/integration/test_styles_reply_message.py index 1371cffcc..379cb28c0 100644 --- a/tests/integration/test_styles_reply_message.py +++ b/tests/integration/test_styles_reply_message.py @@ -8,28 +8,28 @@ def test_styles(mocker, main_window): assert 540 == reply_widget.message.minimumSize().width() # 508px + 32px padding assert 540 == reply_widget.message.maximumSize().width() # 508px + 32px padding - assert 'Source Sans Pro' == reply_widget.message.font().family() + assert "Source Sans Pro" == reply_widget.message.font().family() assert QFont.Normal == reply_widget.message.font().weight() assert 15 == reply_widget.message.font().pixelSize() - assert '#3b3b3b' == reply_widget.message.palette().color(QPalette.Foreground).name() - assert '#ffffff' == reply_widget.message.palette().color(QPalette.Background).name() + assert "#3b3b3b" == reply_widget.message.palette().color(QPalette.Foreground).name() + assert "#ffffff" == reply_widget.message.palette().color(QPalette.Background).name() - reply_widget._set_reply_state('PENDING') + reply_widget._set_reply_state("PENDING") assert 540 == reply_widget.message.minimumSize().width() # 508px + 32px padding assert 540 == reply_widget.message.maximumSize().width() # 508px + 32px padding - assert 'Source Sans Pro' == reply_widget.message.font().family() + assert "Source Sans Pro" == reply_widget.message.font().family() assert QFont.Normal == reply_widget.message.font().weight() assert 15 == reply_widget.message.font().pixelSize() - assert '#a9aaad' == reply_widget.message.palette().color(QPalette.Foreground).name() - assert '#f7f8fc' == reply_widget.message.palette().color(QPalette.Background).name() + assert "#a9aaad" == reply_widget.message.palette().color(QPalette.Foreground).name() + assert "#f7f8fc" == reply_widget.message.palette().color(QPalette.Background).name() - reply_widget._set_reply_state('FAILED') + reply_widget._set_reply_state("FAILED") assert 540 == reply_widget.message.minimumSize().width() # 508px + 32px padding assert 540 == reply_widget.message.maximumSize().width() # 508px + 32px padding - assert 'Source Sans Pro' == reply_widget.message.font().family() + assert "Source Sans Pro" == reply_widget.message.font().family() assert QFont.Normal == reply_widget.message.font().weight() assert 15 == reply_widget.message.font().pixelSize() - assert '#3b3b3b' == reply_widget.message.palette().color(QPalette.Foreground).name() - assert '#ffffff' == reply_widget.message.palette().color(QPalette.Background).name() + assert "#3b3b3b" == reply_widget.message.palette().color(QPalette.Foreground).name() + assert "#ffffff" == reply_widget.message.palette().color(QPalette.Background).name() diff --git a/tests/integration/test_styles_reply_status_bar.py b/tests/integration/test_styles_reply_status_bar.py index 7d7e904f6..27cbb390e 100644 --- a/tests/integration/test_styles_reply_status_bar.py +++ b/tests/integration/test_styles_reply_status_bar.py @@ -8,19 +8,19 @@ def test_styles(mocker, main_window): assert 5 == reply_widget.color_bar.minimumSize().height() assert 5 == reply_widget.color_bar.maximumSize().height() - assert '#0065db' == reply_widget.color_bar.palette().color(QPalette.Background).name() + assert "#0065db" == reply_widget.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; - reply_widget._set_reply_state('PENDING') + reply_widget._set_reply_state("PENDING") assert 5 == reply_widget.color_bar.minimumSize().height() assert 5 == reply_widget.color_bar.maximumSize().height() - assert '#0065db' == reply_widget.color_bar.palette().color(QPalette.Background).name() + assert "#0065db" == reply_widget.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; - reply_widget._set_reply_state('FAILED') + reply_widget._set_reply_state("FAILED") assert 5 == reply_widget.color_bar.minimumSize().height() assert 5 == reply_widget.color_bar.maximumSize().height() - assert '#ff3366' == reply_widget.color_bar.palette().color(QPalette.Background).name() + assert "#ff3366" == reply_widget.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; diff --git a/tests/integration/test_styles_sdclient.py b/tests/integration/test_styles_sdclient.py index 0659596ec..e3961d5d9 100644 --- a/tests/integration/test_styles_sdclient.py +++ b/tests/integration/test_styles_sdclient.py @@ -6,154 +6,154 @@ def test_css(main_window): - assert 'LoginDialog_form' in main_window.styleSheet() + assert "LoginDialog_form" in main_window.styleSheet() def test_class_name_matches_css_object_name(mocker, main_window): # Login Dialog login_dialog = main_window.login_dialog - assert 'LoginDialog' == login_dialog.__class__.__name__ + assert "LoginDialog" == login_dialog.__class__.__name__ form = login_dialog.layout().itemAt(2).widget() - assert 'LoginDialog' in form.objectName() + assert "LoginDialog" in form.objectName() app_version_label = login_dialog.layout().itemAt(4).widget().layout().itemAt(0).widget() - assert 'LoginDialog' in app_version_label.objectName() + assert "LoginDialog" in app_version_label.objectName() login_offline_link = login_dialog.offline_mode - assert 'LoginOfflineLink' == login_offline_link.__class__.__name__ - assert 'LoginOfflineLink' == login_offline_link.objectName() + assert "LoginOfflineLink" == login_offline_link.__class__.__name__ + assert "LoginOfflineLink" == login_offline_link.objectName() login_button = login_dialog.submit - assert 'SignInButton' == login_button.__class__.__name__ - assert 'SignInButton' in login_button.objectName() + assert "SignInButton" == login_button.__class__.__name__ + assert "SignInButton" in login_button.objectName() login_error_bar = login_dialog.error_bar - assert 'LoginErrorBar' == login_error_bar.__class__.__name__ - assert 'LoginErrorBar' in login_error_bar.objectName() - assert 'LoginErrorBar' in login_error_bar.error_icon.objectName() - assert 'LoginErrorBar' in login_error_bar.error_status_bar.objectName() + assert "LoginErrorBar" == login_error_bar.__class__.__name__ + assert "LoginErrorBar" in login_error_bar.objectName() + assert "LoginErrorBar" in login_error_bar.error_icon.objectName() + assert "LoginErrorBar" in login_error_bar.error_status_bar.objectName() # Top Pane sync_icon = main_window.top_pane.sync_icon - assert 'SyncIcon' == sync_icon.__class__.__name__ - assert 'SyncIcon' == sync_icon.objectName() + assert "SyncIcon" == sync_icon.__class__.__name__ + assert "SyncIcon" == sync_icon.objectName() activity_status_bar = main_window.top_pane.activity_status_bar - assert 'ActivityStatusBar' == activity_status_bar.__class__.__name__ - assert 'ActivityStatusBar' == activity_status_bar.objectName() + assert "ActivityStatusBar" == activity_status_bar.__class__.__name__ + assert "ActivityStatusBar" == activity_status_bar.objectName() error_status_bar = main_window.top_pane.error_status_bar - assert 'ErrorStatusBar' == error_status_bar.__class__.__name__ - assert 'ErrorStatusBar' in error_status_bar.vertical_bar.objectName() - assert 'ErrorStatusBar' in error_status_bar.label.objectName() - assert 'ErrorStatusBar' in error_status_bar.status_bar.objectName() + assert "ErrorStatusBar" == error_status_bar.__class__.__name__ + assert "ErrorStatusBar" in error_status_bar.vertical_bar.objectName() + assert "ErrorStatusBar" in error_status_bar.label.objectName() + assert "ErrorStatusBar" in error_status_bar.status_bar.objectName() # Left Pane user_profile = main_window.left_pane.user_profile - assert 'UserProfile' == user_profile.__class__.__name__ - assert 'UserProfile' == user_profile.objectName() - assert 'UserProfile' in user_profile.user_icon.objectName() + assert "UserProfile" == user_profile.__class__.__name__ + assert "UserProfile" == user_profile.objectName() + assert "UserProfile" in user_profile.user_icon.objectName() user_button = user_profile.user_button - assert 'UserButton' == user_button.__class__.__name__ - assert 'UserButton' == user_button.objectName() + assert "UserButton" == user_button.__class__.__name__ + assert "UserButton" == user_button.objectName() login_button = user_profile.login_button - assert 'LoginButton' == login_button.__class__.__name__ - assert 'LoginButton' == login_button.objectName() + assert "LoginButton" == login_button.__class__.__name__ + assert "LoginButton" == login_button.objectName() # Main View main_view = main_window.main_view - assert 'MainView' == main_view.__class__.__name__ - assert 'MainView' == main_view.objectName() - assert 'MainView' in main_view.view_holder.objectName() + assert "MainView" == main_view.__class__.__name__ + assert "MainView" == main_view.objectName() + assert "MainView" in main_view.view_holder.objectName() empty_conversation_view = main_view.empty_conversation_view - 'EmptyConversationView' == empty_conversation_view.__class__.__name__ - 'EmptyConversationView' == empty_conversation_view.objectName() - 'EmptyConversationView' in empty_conversation_view.no_sources.objectName() - 'EmptyConversationView' in empty_conversation_view.no_source_selected.objectName() + "EmptyConversationView" == empty_conversation_view.__class__.__name__ + "EmptyConversationView" == empty_conversation_view.objectName() + "EmptyConversationView" in empty_conversation_view.no_sources.objectName() + "EmptyConversationView" in empty_conversation_view.no_source_selected.objectName() source_list = main_view.source_list - 'SourceList' == source_list.__class__.__name__ - 'SourceList' == source_list.objectName() + "SourceList" == source_list.__class__.__name__ + "SourceList" == source_list.objectName() source_widget = source_list.itemWidget(source_list.item(0)) - assert 'SourceWidget' == source_widget.__class__.__name__ - assert 'SourceWidget' in source_widget.gutter.objectName() - assert 'SourceWidget' in source_widget.summary.objectName() - assert 'SourceWidget' in source_widget.name.objectName() - assert 'SourceWidget' in source_widget.preview.objectName() - assert 'SourceWidget' in source_widget.waiting_delete_confirmation.objectName() - assert 'SourceWidget' in source_widget.metadata.objectName() - assert 'SourceWidget' in source_widget.paperclip.objectName() - assert 'SourceWidget' in source_widget.timestamp.objectName() - assert 'SourceWidget' in source_widget.source_widget.objectName() + assert "SourceWidget" == source_widget.__class__.__name__ + assert "SourceWidget" in source_widget.gutter.objectName() + assert "SourceWidget" in source_widget.summary.objectName() + assert "SourceWidget" in source_widget.name.objectName() + assert "SourceWidget" in source_widget.preview.objectName() + assert "SourceWidget" in source_widget.waiting_delete_confirmation.objectName() + assert "SourceWidget" in source_widget.metadata.objectName() + assert "SourceWidget" in source_widget.paperclip.objectName() + assert "SourceWidget" in source_widget.timestamp.objectName() + assert "SourceWidget" in source_widget.source_widget.objectName() star = source_widget.star - assert 'StarToggleButton' == star.__class__.__name__ - assert 'StarToggleButton' in star.objectName() + assert "StarToggleButton" == star.__class__.__name__ + assert "StarToggleButton" in star.objectName() wrapper = main_view.view_layout.itemAt(0).widget() - assert 'SourceConversationWrapper' == wrapper.__class__.__name__ - assert 'SourceConversationWrapper' in wrapper.waiting_delete_confirmation.objectName() + assert "SourceConversationWrapper" == wrapper.__class__.__name__ + assert "SourceConversationWrapper" in wrapper.waiting_delete_confirmation.objectName() reply_box = wrapper.reply_box - assert 'ReplyBoxWidget' == reply_box.__class__.__name__ - assert 'ReplyBoxWidget' == reply_box.objectName() + assert "ReplyBoxWidget" == reply_box.__class__.__name__ + assert "ReplyBoxWidget" == reply_box.objectName() horizontal_line = reply_box.layout().itemAt(0).widget() - assert 'ReplyBoxWidget' in horizontal_line.objectName() - assert 'ReplyBoxWidget' in reply_box.replybox.objectName() + assert "ReplyBoxWidget" in horizontal_line.objectName() + assert "ReplyBoxWidget" in reply_box.replybox.objectName() reply_text_edit = reply_box.text_edit - assert 'ReplyTextEdit' == reply_text_edit.__class__.__name__ - assert 'ReplyTextEdit' == reply_text_edit.objectName() + assert "ReplyTextEdit" == reply_text_edit.__class__.__name__ + assert "ReplyTextEdit" == reply_text_edit.objectName() compose_a_reply_to = reply_text_edit.placeholder.signed_in.layout().itemAt(0).widget() source_name = reply_text_edit.placeholder.signed_in.layout().itemAt(1).widget() sign_in = reply_text_edit.placeholder.signed_out.layout().itemAt(0).widget() to_compose_reply = reply_text_edit.placeholder.signed_in.layout().itemAt(1).widget() awaiting_key = reply_text_edit.placeholder.signed_out.layout().itemAt(0).widget() from_server = reply_text_edit.placeholder.signed_in.layout().itemAt(1).widget() - assert 'ReplyTextEditPlaceholder' in compose_a_reply_to.objectName() - assert 'ReplyTextEditPlaceholder' in source_name.objectName() - assert 'ReplyTextEditPlaceholder' in sign_in.objectName() - assert 'ReplyTextEditPlaceholder' in to_compose_reply.objectName() - assert 'ReplyTextEditPlaceholder' in awaiting_key.objectName() - assert 'ReplyTextEditPlaceholder' in from_server.objectName() + assert "ReplyTextEditPlaceholder" in compose_a_reply_to.objectName() + assert "ReplyTextEditPlaceholder" in source_name.objectName() + assert "ReplyTextEditPlaceholder" in sign_in.objectName() + assert "ReplyTextEditPlaceholder" in to_compose_reply.objectName() + assert "ReplyTextEditPlaceholder" in awaiting_key.objectName() + assert "ReplyTextEditPlaceholder" in from_server.objectName() conversation_title_bar = wrapper.conversation_title_bar - assert 'SourceProfileShortWidget' == conversation_title_bar.__class__.__name__ + assert "SourceProfileShortWidget" == conversation_title_bar.__class__.__name__ horizontal_line = conversation_title_bar.layout().itemAt(1).widget() - assert 'SourceProfileShortWidget' in horizontal_line.objectName() + assert "SourceProfileShortWidget" in horizontal_line.objectName() menu = conversation_title_bar.layout().itemAt(0).widget().layout().itemAt(3).widget() - assert 'SourceMenuButton' in menu.objectName() + assert "SourceMenuButton" in menu.objectName() last_updated_label = conversation_title_bar.updated - assert 'LastUpdatedLabel' in last_updated_label.objectName() + assert "LastUpdatedLabel" in last_updated_label.objectName() title = conversation_title_bar.layout().itemAt(0).widget().layout().itemAt(0).widget() - assert 'TitleLabel' in title.objectName() + assert "TitleLabel" in title.objectName() conversation_scroll_area = wrapper.conversation_view.scroll - assert 'ConversationScrollArea' == conversation_scroll_area.__class__.__name__ - assert 'ConversationScrollArea' in conversation_scroll_area.widget().objectName() + assert "ConversationScrollArea" == conversation_scroll_area.__class__.__name__ + assert "ConversationScrollArea" in conversation_scroll_area.widget().objectName() file_widget = conversation_scroll_area.widget().layout().itemAt(0).widget() - assert 'FileWidget' == file_widget.__class__.__name__ + assert "FileWidget" == file_widget.__class__.__name__ message_widget = conversation_scroll_area.widget().layout().itemAt(1).widget() - assert 'MessageWidget' == message_widget.__class__.__name__ - assert 'SpeechBubble' in message_widget.speech_bubble.objectName() + assert "MessageWidget" == message_widget.__class__.__name__ + assert "SpeechBubble" in message_widget.speech_bubble.objectName() reply_widget = conversation_scroll_area.widget().layout().itemAt(2).widget() - assert 'ReplyWidget' == reply_widget.__class__.__name__ - assert 'SpeechBubble' in reply_widget.speech_bubble.objectName() + assert "ReplyWidget" == reply_widget.__class__.__name__ + assert "SpeechBubble" in reply_widget.speech_bubble.objectName() error_message = reply_widget.error.layout().itemAt(0).widget() - assert 'ReplyWidget' in error_message.objectName() + assert "ReplyWidget" in error_message.objectName() def test_class_name_matches_css_object_name_for_print_dialog(print_dialog): - assert 'PrintDialog' == print_dialog.__class__.__name__ + assert "PrintDialog" == print_dialog.__class__.__name__ def test_class_name_matches_css_object_name_for_export_dialog(export_dialog): - assert 'ExportDialog' == export_dialog.__class__.__name__ - assert 'ExportDialog' in export_dialog.passphrase_form.objectName() + assert "ExportDialog" == export_dialog.__class__.__name__ + assert "ExportDialog" in export_dialog.passphrase_form.objectName() def test_class_name_matches_css_object_name_for_modal_dialog(modal_dialog): - assert 'ModalDialog' in modal_dialog.header_icon.objectName() - assert 'ModalDialog' in modal_dialog.header_spinner_label.objectName() - assert 'ModalDialog' in modal_dialog.header.objectName() - assert 'ModalDialog' in modal_dialog.header_line.objectName() - assert 'ModalDialog' in modal_dialog.error_details.objectName() - assert 'ModalDialog' in modal_dialog.body.objectName() - assert 'ModalDialog' in modal_dialog.body.objectName() - assert 'ModalDialog' in modal_dialog.continue_button.objectName() + assert "ModalDialog" in modal_dialog.header_icon.objectName() + assert "ModalDialog" in modal_dialog.header_spinner_label.objectName() + assert "ModalDialog" in modal_dialog.header.objectName() + assert "ModalDialog" in modal_dialog.header_line.objectName() + assert "ModalDialog" in modal_dialog.error_details.objectName() + assert "ModalDialog" in modal_dialog.body.objectName() + assert "ModalDialog" in modal_dialog.body.objectName() + assert "ModalDialog" in modal_dialog.continue_button.objectName() window_buttons = modal_dialog.layout().itemAt(5).widget() - assert 'ModalDialog' in window_buttons.objectName() + assert "ModalDialog" in window_buttons.objectName() button_box = window_buttons.layout().itemAt(0).widget() - assert 'ModalDialog' in button_box.objectName() + assert "ModalDialog" in button_box.objectName() def test_styles_for_login_dialog(mocker, main_window): @@ -161,11 +161,11 @@ def test_styles_for_login_dialog(mocker, main_window): form = login_dialog.layout().itemAt(2).widget() form_children_qlabel = form.findChildren(QLabel) for c in form_children_qlabel: - assert 'Montserrat' == c.font().family() + assert "Montserrat" == c.font().family() # TODO: Figure out why font size is QFont.DemiBold - 1 assert QFont.DemiBold - 1 == c.font().weight() assert 13 == c.font().pixelSize() - assert '#ffffff' == c.palette().color(QPalette.Foreground).name() + assert "#ffffff" == c.palette().color(QPalette.Foreground).name() form_children_qlineedit = form.findChildren(QLineEdit) for c in form_children_qlineedit: assert 30 == c.height() # 30px + 0px margin @@ -173,77 +173,77 @@ def test_styles_for_login_dialog(mocker, main_window): # assert `border-radius: 0px;` # assert `padding-left: 5px;` app_version_label = login_dialog.layout().itemAt(4).widget().layout().itemAt(0).widget() - assert '#9fddff' == app_version_label.palette().color(QPalette.Foreground).name() + assert "#9fddff" == app_version_label.palette().color(QPalette.Foreground).name() login_offline_link = login_dialog.offline_mode - assert '#ffffff' == login_offline_link.palette().color(QPalette.Foreground).name() + assert "#ffffff" == login_offline_link.palette().color(QPalette.Foreground).name() # assert `border: none;` # assert `text-decoration: underline; login_button = login_dialog.submit # assert `border: none;` - assert 'Montserrat' == login_button.font().family() + assert "Montserrat" == login_button.font().family() assert QFont.Bold == login_button.font().weight() assert 14 == login_button.font().pixelSize() - assert '#2a319d' == login_button.palette().color(QPalette.Foreground).name() - assert '#05edfe' == login_button.palette().color(QPalette.Background).name() + assert "#2a319d" == login_button.palette().color(QPalette.Foreground).name() + assert "#05edfe" == login_button.palette().color(QPalette.Background).name() # assert `background-color: #85f6fe;` when button is pressed login_error_bar = login_dialog.error_bar login_error_bar_children = login_error_bar.findChildren(QWidget) for c in login_error_bar_children: - assert '#ce0083' == c.palette().color(QPalette.Background).name() - assert '#ffffff' == login_error_bar.error_icon.palette().color(QPalette.Foreground).name() - assert '#ffffff' == login_error_bar.error_status_bar.palette().color(QPalette.Foreground).name() + assert "#ce0083" == c.palette().color(QPalette.Background).name() + assert "#ffffff" == login_error_bar.error_icon.palette().color(QPalette.Foreground).name() + assert "#ffffff" == login_error_bar.error_status_bar.palette().color(QPalette.Foreground).name() def test_styles_for_top_pane(mocker, main_window): sync_icon = main_window.top_pane.sync_icon - assert '#ffffff' == sync_icon.palette().color(QPalette.Base).name() + assert "#ffffff" == sync_icon.palette().color(QPalette.Base).name() # assert 'border: none;' for sync_icon activity_status_bar = main_window.top_pane.activity_status_bar - assert 'Source Sans Pro' == activity_status_bar.font().family() + assert "Source Sans Pro" == activity_status_bar.font().family() assert QFont.Bold == activity_status_bar.font().weight() assert 12 == activity_status_bar.font().pixelSize() - assert '#ffffff' == activity_status_bar.palette().color(QPalette.Base).name() - assert '#d3d8ea' == activity_status_bar.palette().color(QPalette.Foreground).name() + assert "#ffffff" == activity_status_bar.palette().color(QPalette.Base).name() + assert "#d3d8ea" == activity_status_bar.palette().color(QPalette.Foreground).name() error_status_bar = main_window.top_pane.error_status_bar - assert '#ff3366' == error_status_bar.vertical_bar.palette().color(QPalette.Background).name() + assert "#ff3366" == error_status_bar.vertical_bar.palette().color(QPalette.Background).name() # assert 'background-color: qlineargradient(...' for vertical_bar # assert 'background-color: qlineargradient(...'' for status_bar - assert 'Source Sans Pro' == error_status_bar.status_bar.font().family() + assert "Source Sans Pro" == error_status_bar.status_bar.font().family() assert QFont.Normal == error_status_bar.status_bar.font().weight() assert 14 == error_status_bar.status_bar.font().pixelSize() - assert '#0c3e75' == error_status_bar.status_bar.palette().color(QPalette.Foreground).name() + assert "#0c3e75" == error_status_bar.status_bar.palette().color(QPalette.Foreground).name() def test_styles_for_left_pane(mocker, main_window): user_profile = main_window.left_pane.user_profile # assert 'padding: 15px;' - assert '#9211ff' == user_profile.user_icon.palette().color(QPalette.Background).name() + assert "#9211ff" == user_profile.user_icon.palette().color(QPalette.Background).name() # assert 'border: none;' for user_icon # assert 'padding-left: 3px;' for user_icon # assert 'padding-bottom: 4px;' for user_icon - assert 'Source Sans Pro' == user_profile.user_icon.font().family() + assert "Source Sans Pro" == user_profile.user_icon.font().family() assert QFont.Bold == user_profile.user_icon.font().weight() assert 15 == user_profile.user_icon.font().pixelSize() - assert '#ffffff' == user_profile.user_icon.palette().color(QPalette.Foreground).name() + assert "#ffffff" == user_profile.user_icon.palette().color(QPalette.Foreground).name() user_button = user_profile.user_button # assert 'border: none;' - assert 'Source Sans Pro' == user_button.font().family() + assert "Source Sans Pro" == user_button.font().family() assert QFont.Black == user_button.font().weight() assert 12 == user_button.font().pixelSize() - assert '#ffffff' == user_button.palette().color(QPalette.Foreground).name() + assert "#ffffff" == user_button.palette().color(QPalette.Foreground).name() # assert 'text-align: left;' # assert 'outline: none;' for focus # assert 'image: none;' for menu-indicator login_button = user_profile.login_button # assert 'border: none;' - assert '#05edfe' == login_button.palette().color(QPalette.Background).name() - assert 'Montserrat' == login_button.font().family() + assert "#05edfe" == login_button.palette().color(QPalette.Background).name() + assert "Montserrat" == login_button.font().family() assert QFont.Bold == login_button.font().weight() assert 14 == login_button.font().pixelSize() - assert '#2a319d' == login_button.palette().color(QPalette.Foreground).name() + assert "#2a319d" == login_button.palette().color(QPalette.Foreground).name() # assert 'background-color: #85f6fe;' for pressed @@ -252,41 +252,41 @@ def test_styles_for_main_view(mocker, main_window): assert 558 == main_view.height() assert 667 == main_view.view_holder.width() # assert 'border: none;' for view_holder - assert '#f3f5f9' == main_view.view_holder.palette().color(QPalette.Background).name() + assert "#f3f5f9" == main_view.view_holder.palette().color(QPalette.Background).name() no_sources = main_view.empty_conversation_view.no_sources assert 5 == no_sources.layout().count() no_sources_instructions = no_sources.layout().itemAt(0).widget() - assert 'Montserrat' == no_sources_instructions.font().family() + assert "Montserrat" == no_sources_instructions.font().family() assert QFont.DemiBold - 1 == no_sources_instructions.font().weight() assert 35 == no_sources_instructions.font().pixelSize() - assert '#a5b3e9' == no_sources_instructions.palette().color(QPalette.Foreground).name() + assert "#a5b3e9" == no_sources_instructions.palette().color(QPalette.Foreground).name() assert 520 == no_sources_instructions.minimumWidth() assert 600 == no_sources_instructions.maximumWidth() no_sources_spacer1 = no_sources.layout().itemAt(1) assert 35 == no_sources_spacer1.minimumSize().height() assert 35 == no_sources_spacer1.maximumSize().height() no_sources_instruction_details1 = no_sources.layout().itemAt(2).widget() - assert 'Montserrat' == no_sources_instruction_details1.font().family() + assert "Montserrat" == no_sources_instruction_details1.font().family() assert QFont.Normal == no_sources_instruction_details1.font().weight() assert 35 == no_sources_instruction_details1.font().pixelSize() - assert '#a5b3e9' == no_sources_instruction_details1.palette().color(QPalette.Foreground).name() + assert "#a5b3e9" == no_sources_instruction_details1.palette().color(QPalette.Foreground).name() no_sources_spacer2 = no_sources.layout().itemAt(3) assert 35 == no_sources_spacer2.minimumSize().height() assert 35 == no_sources_spacer2.maximumSize().height() no_sources_instruction_details2 = no_sources.layout().itemAt(4).widget() - assert 'Montserrat' == no_sources_instruction_details2.font().family() + assert "Montserrat" == no_sources_instruction_details2.font().family() assert QFont.Normal == no_sources_instruction_details2.font().weight() assert 35 == no_sources_instruction_details2.font().pixelSize() - assert '#a5b3e9' == no_sources_instruction_details2.palette().color(QPalette.Foreground).name() + assert "#a5b3e9" == no_sources_instruction_details2.palette().color(QPalette.Foreground).name() no_source_selected = main_view.empty_conversation_view.no_source_selected assert 6 == no_source_selected.layout().count() no_source_selected_instructions = no_source_selected.layout().itemAt(0).widget() - assert 'Montserrat' == no_source_selected_instructions.font().family() + assert "Montserrat" == no_source_selected_instructions.font().family() assert QFont.DemiBold - 1 == no_source_selected_instructions.font().weight() assert 35 == no_source_selected_instructions.font().pixelSize() - assert '#a5b3e9' == no_source_selected_instructions.palette().color(QPalette.Foreground).name() + assert "#a5b3e9" == no_source_selected_instructions.palette().color(QPalette.Foreground).name() assert 520 == no_source_selected_instructions.minimumWidth() assert 520 == no_source_selected_instructions.maximumWidth() no_source_selected_spacer1 = no_source_selected.layout().itemAt(1) @@ -296,20 +296,20 @@ def test_styles_for_main_view(mocker, main_window): assert (0, 4, 0, 0) == bullet1_bullet.getContentsMargins() 35 == bullet1_bullet.font().pixelSize() QFont.Bold == bullet1_bullet.font().weight() - assert 'Montserrat' == bullet1_bullet.font().family() - assert '#a5b3e9' == bullet1_bullet.palette().color(QPalette.Foreground).name() + assert "Montserrat" == bullet1_bullet.font().family() + assert "#a5b3e9" == bullet1_bullet.palette().color(QPalette.Foreground).name() bullet2_bullet = no_source_selected.layout().itemAt(3).widget().layout().itemAt(0).widget() assert (0, 4, 0, 0) == bullet2_bullet.getContentsMargins() 35 == bullet2_bullet.font().pixelSize() QFont.Bold == bullet2_bullet.font().weight() - assert 'Montserrat' == bullet2_bullet.font().family() - assert '#a5b3e9' == bullet2_bullet.palette().color(QPalette.Foreground).name() + assert "Montserrat" == bullet2_bullet.font().family() + assert "#a5b3e9" == bullet2_bullet.palette().color(QPalette.Foreground).name() bullet3_bullet = no_source_selected.layout().itemAt(4).widget().layout().itemAt(0).widget() assert (0, 4, 0, 0) == bullet3_bullet.getContentsMargins() 35 == bullet3_bullet.font().pixelSize() QFont.Bold == bullet3_bullet.font().weight() - assert 'Montserrat' == bullet3_bullet.font().family() - assert '#a5b3e9' == bullet3_bullet.palette().color(QPalette.Foreground).name() + assert "Montserrat" == bullet3_bullet.font().family() + assert "#a5b3e9" == bullet3_bullet.palette().color(QPalette.Foreground).name() no_source_selected_spacer2 = no_source_selected.layout().itemAt(5) assert (35 * 4) == no_source_selected_spacer2.minimumSize().height() assert (35 * 4) == no_source_selected_spacer2.maximumSize().height() @@ -330,25 +330,25 @@ def test_styles_source_list(mocker, main_window): assert 40 == source_widget.gutter.maximumSize().width() assert 60 == source_widget.metadata.maximumSize().width() preview = source_widget.preview - assert 'Source Sans Pro' == preview.font().family() + assert "Source Sans Pro" == preview.font().family() QFont.Normal == preview.font().weight() 13 == preview.font().pixelSize() - assert '#383838' == preview.palette().color(QPalette.Foreground).name() + assert "#383838" == preview.palette().color(QPalette.Foreground).name() waiting_delete_confirmation = source_widget.waiting_delete_confirmation - assert 'Source Sans Pro' == waiting_delete_confirmation.font().family() + assert "Source Sans Pro" == waiting_delete_confirmation.font().family() QFont.Normal == waiting_delete_confirmation.font().weight() 13 == waiting_delete_confirmation.font().pixelSize() - assert '#ff3366' == waiting_delete_confirmation.palette().color(QPalette.Foreground).name() + assert "#ff3366" == waiting_delete_confirmation.palette().color(QPalette.Foreground).name() name = source_widget.name - assert 'Montserrat' == name.font().family() + assert "Montserrat" == name.font().family() QFont.Normal == name.font().weight() 13 == name.font().pixelSize() - assert '#383838' == name.palette().color(QPalette.Foreground).name() + assert "#383838" == name.palette().color(QPalette.Foreground).name() timestamp = source_widget.timestamp - assert 'Montserrat' == timestamp.font().family() + assert "Montserrat" == timestamp.font().family() QFont.Normal == timestamp.font().weight() 13 == timestamp.font().pixelSize() - assert '#383838' == timestamp.palette().color(QPalette.Foreground).name() + assert "#383838" == timestamp.palette().color(QPalette.Foreground).name() # star = source_widget.star # assert 'border: none;' @@ -356,19 +356,19 @@ def test_styles_source_list(mocker, main_window): def test_styles_for_conversation_view(mocker, main_window): wrapper = main_window.main_view.view_layout.itemAt(0).widget() waiting_delete_confirmation = wrapper.waiting_delete_confirmation - assert 'Montserrat' == waiting_delete_confirmation.font().family() + assert "Montserrat" == waiting_delete_confirmation.font().family() assert QFont.DemiBold - 1 == waiting_delete_confirmation.font().weight() assert 40 == waiting_delete_confirmation.font().pixelSize() - assert '#a5b3e9' == waiting_delete_confirmation.palette().color(QPalette.Foreground).name() + assert "#a5b3e9" == waiting_delete_confirmation.palette().color(QPalette.Foreground).name() # assert 'text-align: left;' # assert 'padding-bottom: 264px;' # assert 'padding-right: 195px;' reply_box = wrapper.reply_box assert 173 == reply_box.minimumSize().height() assert 173 == reply_box.maximumSize().height() - assert '#efefef' == reply_box.replybox.palette().color(QPalette.Background).name() + assert "#efefef" == reply_box.replybox.palette().color(QPalette.Background).name() reply_box.set_logged_in() - assert '#ffffff' == reply_box.replybox.palette().color(QPalette.Background).name() + assert "#ffffff" == reply_box.replybox.palette().color(QPalette.Background).name() reply_box_children = reply_box.findChildren(QPushButton) hover = QEvent(QEvent.HoverEnter) for c in reply_box_children: @@ -386,7 +386,7 @@ def test_styles_for_conversation_view(mocker, main_window): assert 157 == horizontal_line.palette().color(QPalette.Background).blue() # assert 'border: none;' for horizontal line reply_text_edit = reply_box.text_edit - assert 'Montserrat' == reply_text_edit.font().family() + assert "Montserrat" == reply_text_edit.font().family() assert QFont.Normal == reply_text_edit.font().weight() assert 18 == reply_text_edit.font().pixelSize() # assert 'border: none;' @@ -410,50 +410,50 @@ def test_styles_for_conversation_view(mocker, main_window): # assert 'padding-left: 8px;' # assert 'image: none;' for 'menu-indicator' last_updated_label = conversation_title_bar.updated - assert 'Montserrat' == last_updated_label.font().family() + assert "Montserrat" == last_updated_label.font().family() assert QFont.Light == last_updated_label.font().weight() assert 24 == last_updated_label.font().pixelSize() - assert '#2a319d' == last_updated_label.palette().color(QPalette.Foreground).name() + assert "#2a319d" == last_updated_label.palette().color(QPalette.Foreground).name() title = conversation_title_bar.layout().itemAt(0).widget().layout().itemAt(0).widget() - assert 'Montserrat' == title.font().family() + assert "Montserrat" == title.font().family() assert QFont.Normal == title.font().weight() assert 24 == title.font().pixelSize() - assert '#2a319d' == title.palette().color(QPalette.Foreground).name() + assert "#2a319d" == title.palette().color(QPalette.Foreground).name() # assert 'padding-left: 4px;' for title conversation_scrollarea = wrapper.conversation_view.scroll - assert '#f3f5f9' == conversation_scrollarea.palette().color(QPalette.Background).name() + assert "#f3f5f9" == conversation_scrollarea.palette().color(QPalette.Background).name() # assert 'border: none;' for conversation_scrollarea - assert '#f3f5f9' == conversation_scrollarea.widget().palette().color(QPalette.Background).name() + assert "#f3f5f9" == conversation_scrollarea.widget().palette().color(QPalette.Background).name() file_widget = conversation_scrollarea.widget().layout().itemAt(0).widget() assert 540 == file_widget.minimumSize().width() assert 540 == file_widget.maximumSize().width() assert 137 == file_widget.file_options.minimumSize().width() - assert 'Source Sans Pro' == file_widget.export_button.font().family() + assert "Source Sans Pro" == file_widget.export_button.font().family() assert QFont.DemiBold - 1 == file_widget.export_button.font().weight() assert 13 == file_widget.export_button.font().pixelSize() - assert '#2a319d' == file_widget.export_button.palette().color(QPalette.Foreground).name() + assert "#2a319d" == file_widget.export_button.palette().color(QPalette.Foreground).name() # assert 'border: none;' for export_print - assert 'Source Sans Pro' == file_widget.file_name.font().family() + assert "Source Sans Pro" == file_widget.file_name.font().family() assert QFont.Bold == file_widget.file_name.font().weight() assert 13 == file_widget.file_name.font().pixelSize() - assert '#2a319d' == file_widget.file_name.palette().color(QPalette.Foreground).name() + assert "#2a319d" == file_widget.file_name.palette().color(QPalette.Foreground).name() # hover = QEvent(QEvent.HoverEnter) # file_widget.file_name.eventFilter(file_widget.file_name, hover) # assert '#05a6fe'== file_widget.file_name.palette().color(QPalette.Foreground).name() - assert 'Source Sans Pro' == file_widget.no_file_name.font().family() + assert "Source Sans Pro" == file_widget.no_file_name.font().family() # TODO: Figure out why font size is QFont.Light + 12 assert QFont.Light + 12 == file_widget.no_file_name.font().weight() assert 13 == file_widget.no_file_name.font().pixelSize() - assert '#a5b3e9' == file_widget.no_file_name.palette().color(QPalette.Foreground).name() + assert "#a5b3e9" == file_widget.no_file_name.palette().color(QPalette.Foreground).name() assert 48 == file_widget.file_size.minimumSize().width() assert 48 == file_widget.file_size.maximumSize().width() - assert 'Source Sans Pro' == file_widget.file_size.font().family() + assert "Source Sans Pro" == file_widget.file_size.font().family() assert QFont.Normal == file_widget.file_size.font().weight() assert 13 == file_widget.file_size.font().pixelSize() - assert '#2a319d' == file_widget.file_size.palette().color(QPalette.Foreground).name() + assert "#2a319d" == file_widget.file_size.palette().color(QPalette.Foreground).name() assert 2 == file_widget.horizontal_line.minimumSize().height() # 2px + 0px margin assert 2 == file_widget.horizontal_line.maximumSize().height() # 2px + 0px margin @@ -467,16 +467,16 @@ def test_styles_for_conversation_view(mocker, main_window): message_widget = conversation_scrollarea.widget().layout().itemAt(1).widget() assert 540 == message_widget.speech_bubble.minimumSize().width() assert 540 == message_widget.speech_bubble.maximumSize().width() - assert '#ffffff' == message_widget.speech_bubble.palette().color(QPalette.Background).name() + assert "#ffffff" == message_widget.speech_bubble.palette().color(QPalette.Background).name() reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget() assert 540 == reply_widget.speech_bubble.minimumSize().width() assert 540 == reply_widget.speech_bubble.maximumSize().width() - assert '#ffffff' == reply_widget.speech_bubble.palette().color(QPalette.Background).name() + assert "#ffffff" == reply_widget.speech_bubble.palette().color(QPalette.Background).name() reply_widget_error_message = reply_widget.error.layout().itemAt(0).widget() - assert 'Source Sans Pro' == reply_widget_error_message.font().family() + assert "Source Sans Pro" == reply_widget_error_message.font().family() assert QFont.DemiBold - 1 == reply_widget_error_message.font().weight() assert 13 == reply_widget_error_message.font().pixelSize() - assert '#ff3366' == reply_widget_error_message.palette().color(QPalette.Foreground).name() + assert "#ff3366" == reply_widget_error_message.palette().color(QPalette.Foreground).name() def test_styles_for_modal_dialog(modal_dialog): @@ -484,7 +484,7 @@ def test_styles_for_modal_dialog(modal_dialog): assert 800 == modal_dialog.maximumSize().width() assert 300 == modal_dialog.minimumSize().height() assert 800 == modal_dialog.maximumSize().height() - assert '#ffffff' == modal_dialog.palette().color(QPalette.Background).name() + assert "#ffffff" == modal_dialog.palette().color(QPalette.Background).name() assert 110 == modal_dialog.header_icon.minimumSize().width() # 80px + 30px margin assert 110 == modal_dialog.header_icon.maximumSize().width() # 80px + 30px margin assert 64 == modal_dialog.header_icon.minimumSize().height() # 64px + 0px margin @@ -495,10 +495,10 @@ def test_styles_for_modal_dialog(modal_dialog): assert 64 == modal_dialog.header_spinner_label.maximumSize().height() # 64px + 0px margin assert 68 == modal_dialog.header.minimumSize().height() # 68px + 0px margin assert 68 == modal_dialog.header.maximumSize().height() # 68px + 0px margin - assert 'Montserrat' == modal_dialog.header.font().family() + assert "Montserrat" == modal_dialog.header.font().family() assert QFont.Bold == modal_dialog.header.font().weight() assert 24 == modal_dialog.header.font().pixelSize() - assert '#2a319d' == modal_dialog.header.palette().color(QPalette.Foreground).name() + assert "#2a319d" == modal_dialog.header.palette().color(QPalette.Foreground).name() assert (4, 0, 0, 0) == modal_dialog.header.getContentsMargins() assert 22 == modal_dialog.header_line.minimumSize().height() # 2px + 20px margin assert 22 == modal_dialog.header_line.maximumSize().height() # 2px + 20px margin @@ -510,15 +510,15 @@ def test_styles_for_modal_dialog(modal_dialog): assert 49 == modal_dialog.header_line.palette().color(QPalette.Background).green() assert 157 == modal_dialog.header_line.palette().color(QPalette.Background).blue() - assert 'Montserrat' == modal_dialog.body.font().family() + assert "Montserrat" == modal_dialog.body.font().family() assert 16 == modal_dialog.body.font().pixelSize() - assert '#302aa3' == modal_dialog.body.palette().color(QPalette.Foreground).name() + assert "#302aa3" == modal_dialog.body.palette().color(QPalette.Foreground).name() window_buttons = modal_dialog.layout().itemAt(5).widget() button_box = window_buttons.layout().itemAt(0).widget() button_box_children = button_box.findChildren(QPushButton) for c in button_box_children: # assert 44 == c.height() # 40px + 4px of border - assert 'Montserrat' == c.font().family() + assert "Montserrat" == c.font().family() assert QFont.DemiBold - 1 == c.font().weight() assert 15 == c.font().pixelSize() # assert '#2a319d' == c.palette().color(QPalette.Foreground).name() @@ -540,7 +540,7 @@ def test_styles_for_print_dialog(print_dialog): assert 800 == print_dialog.maximumSize().width() assert 300 == print_dialog.minimumSize().height() assert 800 == print_dialog.maximumSize().height() - assert '#ffffff' == print_dialog.palette().color(QPalette.Background).name() + assert "#ffffff" == print_dialog.palette().color(QPalette.Background).name() assert 110 == print_dialog.header_icon.minimumSize().width() # 80px + 30px margin assert 110 == print_dialog.header_icon.maximumSize().width() # 80px + 30px margin assert 64 == print_dialog.header_icon.minimumSize().height() # 64px + 0px margin @@ -551,10 +551,10 @@ def test_styles_for_print_dialog(print_dialog): assert 64 == print_dialog.header_spinner_label.maximumSize().height() # 64px + 0px margin assert 68 == print_dialog.header.minimumSize().height() # 68px + 0px margin assert 68 == print_dialog.header.maximumSize().height() # 68px + 0px margin - assert 'Montserrat' == print_dialog.header.font().family() + assert "Montserrat" == print_dialog.header.font().family() assert QFont.Bold == print_dialog.header.font().weight() assert 24 == print_dialog.header.font().pixelSize() - assert '#2a319d' == print_dialog.header.palette().color(QPalette.Foreground).name() + assert "#2a319d" == print_dialog.header.palette().color(QPalette.Foreground).name() assert (4, 0, 0, 0) == print_dialog.header.getContentsMargins() assert 22 == print_dialog.header_line.minimumSize().height() # 2px + 20px margin assert 22 == print_dialog.header_line.maximumSize().height() # 2px + 20px margin @@ -566,15 +566,15 @@ def test_styles_for_print_dialog(print_dialog): assert 49 == print_dialog.header_line.palette().color(QPalette.Background).green() assert 157 == print_dialog.header_line.palette().color(QPalette.Background).blue() - assert 'Montserrat' == print_dialog.body.font().family() + assert "Montserrat" == print_dialog.body.font().family() assert 16 == print_dialog.body.font().pixelSize() - assert '#302aa3' == print_dialog.body.palette().color(QPalette.Foreground).name() + assert "#302aa3" == print_dialog.body.palette().color(QPalette.Foreground).name() window_buttons = print_dialog.layout().itemAt(5).widget() button_box = window_buttons.layout().itemAt(0).widget() button_box_children = button_box.findChildren(QPushButton) for c in button_box_children: # assert 44 == c.height() # 40px + 4px of border - assert 'Montserrat' == c.font().family() + assert "Montserrat" == c.font().family() assert QFont.DemiBold - 1 == c.font().weight() assert 15 == c.font().pixelSize() # assert '#2a319d' == c.palette().color(QPalette.Foreground).name() @@ -596,7 +596,7 @@ def test_styles_for_export_dialog(export_dialog): assert 800 == export_dialog.maximumSize().width() assert 300 == export_dialog.minimumSize().height() assert 800 == export_dialog.maximumSize().height() - assert '#ffffff' == export_dialog.palette().color(QPalette.Background).name() + assert "#ffffff" == export_dialog.palette().color(QPalette.Background).name() assert 110 == export_dialog.header_icon.minimumSize().width() # 80px + 30px margin assert 110 == export_dialog.header_icon.maximumSize().width() # 80px + 30px margin assert 64 == export_dialog.header_icon.minimumSize().height() # 64px + 0px margin @@ -607,10 +607,10 @@ def test_styles_for_export_dialog(export_dialog): assert 64 == export_dialog.header_spinner_label.maximumSize().height() # 64px + 0px margin assert 68 == export_dialog.header.minimumSize().height() # 68px + 0px margin assert 68 == export_dialog.header.maximumSize().height() # 68px + 0px margin - assert 'Montserrat' == export_dialog.header.font().family() + assert "Montserrat" == export_dialog.header.font().family() assert QFont.Bold == export_dialog.header.font().weight() assert 24 == export_dialog.header.font().pixelSize() - assert '#2a319d' == export_dialog.header.palette().color(QPalette.Foreground).name() + assert "#2a319d" == export_dialog.header.palette().color(QPalette.Foreground).name() assert (4, 0, 0, 0) == export_dialog.header.getContentsMargins() assert 22 == export_dialog.header_line.minimumSize().height() # 2px + 20px margin assert 22 == export_dialog.header_line.maximumSize().height() # 2px + 20px margin @@ -622,15 +622,15 @@ def test_styles_for_export_dialog(export_dialog): assert 49 == export_dialog.header_line.palette().color(QPalette.Background).green() assert 157 == export_dialog.header_line.palette().color(QPalette.Background).blue() - assert 'Montserrat' == export_dialog.body.font().family() + assert "Montserrat" == export_dialog.body.font().family() assert 16 == export_dialog.body.font().pixelSize() - assert '#302aa3' == export_dialog.body.palette().color(QPalette.Foreground).name() + assert "#302aa3" == export_dialog.body.palette().color(QPalette.Foreground).name() window_buttons = export_dialog.layout().itemAt(5).widget() button_box = window_buttons.layout().itemAt(0).widget() button_box_children = button_box.findChildren(QPushButton) for c in button_box_children: assert 44 == c.height() # 40px + 4px of border - assert 'Montserrat' == c.font().family() + assert "Montserrat" == c.font().family() assert QFont.DemiBold - 1 == c.font().weight() assert 15 == c.font().pixelSize() # assert '#2a319d' == c.palette().color(QPalette.Foreground).name() @@ -648,15 +648,15 @@ def test_styles_for_export_dialog(export_dialog): passphrase_children_qlabel = export_dialog.passphrase_form.findChildren(QLabel) for c in passphrase_children_qlabel: - assert 'Montserrat' == c.font().family() + assert "Montserrat" == c.font().family() assert QFont.DemiBold - 1 == c.font().weight() assert 12 == c.font().pixelSize() - assert '#2a319d' == c.palette().color(QPalette.Foreground).name() + assert "#2a319d" == c.palette().color(QPalette.Foreground).name() # assert 'padding-top: 6px;' form_children_qlineedit = export_dialog.passphrase_form.findChildren(QLineEdit) for c in form_children_qlineedit: assert 32 == c.minimumSize().height() # 30px + 2px padding-bottom assert 32 == c.maximumSize().height() # 30px + 2px padding-bottom - assert '#f8f8f8' == c.palette().color(QPalette.Background).name() + assert "#f8f8f8" == c.palette().color(QPalette.Background).name() # assert 'border-radius: 0px;' diff --git a/tests/integration/test_styles_speech_bubble_message.py b/tests/integration/test_styles_speech_bubble_message.py index d70e6eebd..391540a4d 100644 --- a/tests/integration/test_styles_speech_bubble_message.py +++ b/tests/integration/test_styles_speech_bubble_message.py @@ -8,20 +8,20 @@ def test_styles(mocker, main_window): assert 540 == speech_bubble.message.minimumSize().width() # 508px + 32px padding assert 540 == speech_bubble.message.maximumSize().width() # 508px + 32px padding - assert 'Source Sans Pro' == speech_bubble.message.font().family() + assert "Source Sans Pro" == speech_bubble.message.font().family() assert QFont.Normal == speech_bubble.message.font().weight() assert 15 == speech_bubble.message.font().pixelSize() - assert '#3b3b3b' == speech_bubble.message.palette().color(QPalette.Foreground).name() - assert '#ffffff' == speech_bubble.message.palette().color(QPalette.Background).name() + assert "#3b3b3b" == speech_bubble.message.palette().color(QPalette.Foreground).name() + assert "#ffffff" == speech_bubble.message.palette().color(QPalette.Background).name() - speech_bubble.set_error('123', speech_bubble.uuid, speech_bubble.message.text()) + speech_bubble.set_error("123", speech_bubble.uuid, speech_bubble.message.text()) assert 540 == speech_bubble.message.minimumSize().width() # 508px + 32px padding assert 540 == speech_bubble.message.maximumSize().width() # 508px + 32px padding - assert 'Source Sans Pro' == speech_bubble.message.font().family() + assert "Source Sans Pro" == speech_bubble.message.font().family() assert QFont.Normal == speech_bubble.message.font().weight() assert 15 == speech_bubble.message.font().pixelSize() - assert '#3b3b3b' == speech_bubble.message.palette().color(QPalette.Foreground).name() + assert "#3b3b3b" == speech_bubble.message.palette().color(QPalette.Foreground).name() assert speech_bubble.message.font().italic() assert 153 == round(255 * 0.6) # sanity check assert 153 == speech_bubble.message.palette().color(QPalette.Background).rgba64().alpha8() diff --git a/tests/integration/test_styles_speech_bubble_status_bar.py b/tests/integration/test_styles_speech_bubble_status_bar.py index 2cf382fc7..788501e24 100644 --- a/tests/integration/test_styles_speech_bubble_status_bar.py +++ b/tests/integration/test_styles_speech_bubble_status_bar.py @@ -8,12 +8,12 @@ def test_styles(mocker, main_window): assert 5 == speech_bubble.color_bar.minimumSize().height() assert 5 == speech_bubble.color_bar.maximumSize().height() - assert '#102781' == speech_bubble.color_bar.palette().color(QPalette.Background).name() + assert "#102781" == speech_bubble.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; - speech_bubble.set_error('123', speech_bubble.uuid, speech_bubble.message.text()) + speech_bubble.set_error("123", speech_bubble.uuid, speech_bubble.message.text()) assert 5 == speech_bubble.color_bar.minimumSize().height() assert 5 == speech_bubble.color_bar.maximumSize().height() - assert '#bcbfcd' == speech_bubble.color_bar.palette().color(QPalette.Background).name() + assert "#bcbfcd" == speech_bubble.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; diff --git a/tests/test_alembic.py b/tests/test_alembic.py index 327b9cee6..75ee7193d 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -1,50 +1,56 @@ # -*- coding: utf-8 -*- import os -import pytest import re import subprocess +from os import path + +import pytest +from sqlalchemy import text from alembic.config import Config as AlembicConfig from alembic.script import ScriptDirectory -from os import path -from sqlalchemy import text +from securedrop_client.db import Base, convention, make_session_maker from . import conftest -from securedrop_client.db import make_session_maker, Base, convention -MIGRATION_PATH = path.join(path.dirname(__file__), '..', 'alembic', 'versions') +MIGRATION_PATH = path.join(path.dirname(__file__), "..", "alembic", "versions") -ALL_MIGRATIONS = [x.split('.')[0].split('_')[0] - for x in os.listdir(MIGRATION_PATH) - if x.endswith('.py')] +ALL_MIGRATIONS = [ + x.split(".")[0].split("_")[0] for x in os.listdir(MIGRATION_PATH) if x.endswith(".py") +] -WHITESPACE_REGEX = re.compile(r'\s+') +WHITESPACE_REGEX = re.compile(r"\s+") def list_migrations(cfg_path, head): cfg = AlembicConfig(cfg_path) script = ScriptDirectory.from_config(cfg) - migrations = [x.revision - for x in script.walk_revisions(base='base', head=head)] + migrations = [x.revision for x in script.walk_revisions(base="base", head=head)] migrations.reverse() return migrations def upgrade(alembic_config, migration): - subprocess.check_call(['alembic', 'upgrade', migration], cwd=path.dirname(alembic_config)) + subprocess.check_call(["alembic", "upgrade", migration], cwd=path.dirname(alembic_config)) def downgrade(alembic_config, migration): - subprocess.check_call(['alembic', 'downgrade', migration], cwd=path.dirname(alembic_config)) + subprocess.check_call(["alembic", "downgrade", migration], cwd=path.dirname(alembic_config)) def get_schema(session): - result = list(session.execute(text(''' + result = list( + session.execute( + text( + """ SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name, tbl_name - '''))) + """ + ) + ) + ) return {(x[0], x[1], x[2]): x[3] for x in result} @@ -52,20 +58,19 @@ def get_schema(session): def assert_schemas_equal(left, right): for (k, v) in left.items(): if k not in right: - raise AssertionError('Left contained {} but right did not'.format(k)) + raise AssertionError("Left contained {} but right did not".format(k)) if not ddl_equal(v, right[k]): raise AssertionError( - 'Schema for {} did not match:\nLeft:\n{}\nRight:\n{}' - .format(k, v, right[k])) + "Schema for {} did not match:\nLeft:\n{}\nRight:\n{}".format(k, v, right[k]) + ) right.pop(k) if right: - raise AssertionError( - 'Right had additional tables: {}'.format(right.keys())) + raise AssertionError("Right had additional tables: {}".format(right.keys())) def ddl_equal(left, right): - ''' + """ Check the "tokenized" DDL is equivalent because, because sometimes Alembic schemas append columns on the same line to the DDL comes out like: @@ -75,7 +80,7 @@ def ddl_equal(left, right): column1 TEXT NOT NULL, column2 TEXT NOT NULL - ''' + """ # ignore the autoindex cases if left is None and right is None: return True @@ -84,19 +89,19 @@ def ddl_equal(left, right): right = [x for x in WHITESPACE_REGEX.split(right) if x] # Strip commas and quotes - left = [x.replace("\"", "").replace(",", "") for x in left] - right = [x.replace("\"", "").replace(",", "") for x in right] + left = [x.replace('"', "").replace(",", "") for x in left] + right = [x.replace('"', "").replace(",", "") for x in right] return sorted(left) == sorted(right) def test_alembic_head_matches_db_models(tmpdir): - ''' + """ This test is to make sure that our database models in `db.py` are always in sync with the schema generated by `alembic upgrade head`. - ''' - models_homedir = str(tmpdir.mkdir('models')) - subprocess.check_call(['sqlite3', os.path.join(models_homedir, 'svs.sqlite'), '.databases']) + """ + models_homedir = str(tmpdir.mkdir("models")) + subprocess.check_call(["sqlite3", os.path.join(models_homedir, "svs.sqlite"), ".databases"]) session_maker = make_session_maker(models_homedir) session = session_maker() @@ -106,32 +111,31 @@ def test_alembic_head_matches_db_models(tmpdir): Base.metadata.drop_all(bind=session.get_bind()) session.close() - alembic_homedir = str(tmpdir.mkdir('alembic')) - subprocess.check_call(['sqlite3', os.path.join(alembic_homedir, 'svs.sqlite'), '.databases']) + alembic_homedir = str(tmpdir.mkdir("alembic")) + subprocess.check_call(["sqlite3", os.path.join(alembic_homedir, "svs.sqlite"), ".databases"]) session_maker = make_session_maker(alembic_homedir) session = session_maker() alembic_config = conftest._alembic_config(alembic_homedir) - upgrade(alembic_config, 'head') + upgrade(alembic_config, "head") alembic_schema = get_schema(session) Base.metadata.drop_all(bind=session.get_bind()) session.close() # The initial migration creates the table 'alembic_version', but this is # not present in the schema created by `Base.metadata.create_all()`. - alembic_schema = {k: v for k, v in alembic_schema.items() - if k[2] != 'alembic_version'} + alembic_schema = {k: v for k, v in alembic_schema.items() if k[2] != "alembic_version"} assert_schemas_equal(alembic_schema, models_schema) -@pytest.mark.parametrize('migration', ALL_MIGRATIONS) +@pytest.mark.parametrize("migration", ALL_MIGRATIONS) def test_alembic_migration_upgrade(alembic_config, config, migration): # run migrations in sequence from base -> head for mig in list_migrations(alembic_config, migration): upgrade(alembic_config, mig) -@pytest.mark.parametrize('migration', ALL_MIGRATIONS) +@pytest.mark.parametrize("migration", ALL_MIGRATIONS) def test_alembic_migration_downgrade(alembic_config, config, migration): # upgrade to the parameterized test case ("head") upgrade(alembic_config, migration) @@ -144,10 +148,8 @@ def test_alembic_migration_downgrade(alembic_config, config, migration): downgrade(alembic_config, mig) -@pytest.mark.parametrize('migration', ALL_MIGRATIONS) -def test_schema_unchanged_after_up_then_downgrade(alembic_config, - tmpdir, - migration): +@pytest.mark.parametrize("migration", ALL_MIGRATIONS) +def test_schema_unchanged_after_up_then_downgrade(alembic_config, tmpdir, migration): migrations = list_migrations(alembic_config, migration) if len(migrations) > 1: @@ -158,18 +160,18 @@ def test_schema_unchanged_after_up_then_downgrade(alembic_config, # get the database to some base state. pass - session = make_session_maker(str(tmpdir.mkdir('original')))() + session = make_session_maker(str(tmpdir.mkdir("original")))() original_schema = get_schema(session) - upgrade(alembic_config, '+1') - downgrade(alembic_config, '-1') + upgrade(alembic_config, "+1") + downgrade(alembic_config, "-1") - session = make_session_maker(str(tmpdir.mkdir('reverted')))() + session = make_session_maker(str(tmpdir.mkdir("reverted")))() reverted_schema = get_schema(session) # The initial migration is a degenerate case because it creates the table # 'alembic_version', but rolling back the migration doesn't clear it. if len(migrations) == 1: - reverted_schema = {k: v for k, v in reverted_schema.items() if k[2] != 'alembic_version'} + reverted_schema = {k: v for k, v in reverted_schema.items() if k[2] != "alembic_version"} assert_schemas_equal(reverted_schema, original_schema) diff --git a/tests/test_app.py b/tests/test_app.py index c6deba615..d55fd72f7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,63 +3,74 @@ """ import os import platform -import pytest import sys + +import pytest from PyQt5.QtWidgets import QApplication -from securedrop_client.app import ENCODING, excepthook, configure_logging, \ - start_app, arg_parser, DEFAULT_SDC_HOME, run, configure_signal_handlers, \ - prevent_second_instance, configure_locale_and_language + +from securedrop_client.app import ( + DEFAULT_SDC_HOME, + ENCODING, + arg_parser, + configure_locale_and_language, + configure_logging, + configure_signal_handlers, + excepthook, + prevent_second_instance, + run, + start_app, +) app = QApplication([]) def test_application_sets_en_as_default_language_code(mocker): - mocker.patch('locale.getdefaultlocale', return_value=(None, None)) + mocker.patch("locale.getdefaultlocale", return_value=(None, None)) language_code = configure_locale_and_language() - assert language_code == 'en' + assert language_code == "en" def test_excepthook(mocker): """ Ensure the custom excepthook logs the error and calls sys.exit. """ - ex = Exception('BANG!') + ex = Exception("BANG!") exc_args = (type(ex), ex, ex.__traceback__) - mock_error = mocker.patch('securedrop_client.app.logging.error') - mock_exit = mocker.patch('securedrop_client.app.sys.exit') + mock_error = mocker.patch("securedrop_client.app.logging.error") + mock_exit = mocker.patch("securedrop_client.app.sys.exit") excepthook(*exc_args) - mock_error.assert_called_once_with('Unrecoverable error', exc_info=exc_args) + mock_error.assert_called_once_with("Unrecoverable error", exc_info=exc_args) mock_exit.assert_called_once_with(1) -@pytest.mark.skipif(platform.system() != 'Linux', reason="this test fails on mac") +@pytest.mark.skipif(platform.system() != "Linux", reason="this test fails on mac") def test_configure_logging(homedir, mocker): """ Ensure logging directory is created and logging is configured in the expected (rotating logs) manner. """ - mock_log_conf = mocker.patch('securedrop_client.app.TimedRotatingFileHandler') - mock_log_conf_sys = mocker.patch('securedrop_client.app.SysLogHandler') - mock_logging = mocker.patch('securedrop_client.app.logging') - mock_log_file = os.path.join(homedir, 'logs', 'client.log') + mock_log_conf = mocker.patch("securedrop_client.app.TimedRotatingFileHandler") + mock_log_conf_sys = mocker.patch("securedrop_client.app.SysLogHandler") + mock_logging = mocker.patch("securedrop_client.app.logging") + mock_log_file = os.path.join(homedir, "logs", "client.log") configure_logging(homedir) - mock_log_conf.assert_called_once_with(mock_log_file, when='midnight', - backupCount=5, delay=0, - encoding=ENCODING) + mock_log_conf.assert_called_once_with( + mock_log_file, when="midnight", backupCount=5, delay=0, encoding=ENCODING + ) mock_log_conf_sys.assert_called_once_with(address="/dev/log") mock_logging.getLogger.assert_called_once_with() assert sys.excepthook == excepthook -@pytest.mark.skipif(platform.system() != 'Linux', - reason="concurrent app prevention skipped on non Linux") +@pytest.mark.skipif( + platform.system() != "Linux", reason="concurrent app prevention skipped on non Linux" +) class TestSecondInstancePrevention(object): - @staticmethod def mock_app(mocker): mock_app = mocker.MagicMock() - mock_app.applicationName = mocker.MagicMock(return_value='sd') + mock_app.applicationName = mocker.MagicMock(return_value="sd") return mock_app @staticmethod @@ -79,34 +90,34 @@ def kernel_bind(addr): return socket_mock def test_diff_name(self, mocker): - mock_exit = mocker.patch('securedrop_client.app.sys.exit') - mocker.patch('securedrop_client.app.QMessageBox') + mock_exit = mocker.patch("securedrop_client.app.sys.exit") + mocker.patch("securedrop_client.app.QMessageBox") mock_socket = self.socket_mock_generator(mocker) mock_app = self.mock_app(mocker) - mocker.patch('securedrop_client.app.socket', new=mock_socket) - prevent_second_instance(mock_app, 'name1') - prevent_second_instance(mock_app, 'name2') + mocker.patch("securedrop_client.app.socket", new=mock_socket) + prevent_second_instance(mock_app, "name1") + prevent_second_instance(mock_app, "name2") mock_exit.assert_not_called() def test_same_name(self, mocker): - mock_exit = mocker.patch('securedrop_client.app.sys.exit') - mocker.patch('securedrop_client.app.QMessageBox') + mock_exit = mocker.patch("securedrop_client.app.sys.exit") + mocker.patch("securedrop_client.app.QMessageBox") mock_socket = self.socket_mock_generator(mocker) mock_app = self.mock_app(mocker) - mocker.patch('securedrop_client.app.socket', new=mock_socket) - prevent_second_instance(mock_app, 'name1') - prevent_second_instance(mock_app, 'name1') + mocker.patch("securedrop_client.app.socket", new=mock_socket) + prevent_second_instance(mock_app, "name1") + prevent_second_instance(mock_app, "name1") mock_exit.assert_any_call() def test_unknown_kernel_error(self, mocker): - mocker.patch('securedrop_client.app.sys.exit') - mocker.patch('securedrop_client.app.QMessageBox') + mocker.patch("securedrop_client.app.sys.exit") + mocker.patch("securedrop_client.app.QMessageBox") mock_socket = self.socket_mock_generator(mocker, 131) # crazy unexpected error mock_app = self.mock_app(mocker) - mocker.patch('securedrop_client.app.socket', new=mock_socket) + mocker.patch("securedrop_client.app.socket", new=mock_socket) with pytest.raises(OSError): - prevent_second_instance(mock_app, 'name1') - prevent_second_instance(mock_app, 'name1') + prevent_second_instance(mock_app, "name1") + prevent_second_instance(mock_app, "name1") def test_start_app(homedir, mocker): @@ -119,56 +130,31 @@ def test_start_app(homedir, mocker): mock_args.sdc_home = str(homedir) mock_args.proxy = False - mocker.patch('securedrop_client.app.configure_logging') - mock_app = mocker.patch('securedrop_client.app.QApplication') - mock_win = mocker.patch('securedrop_client.app.Window') - mocker.patch('securedrop_client.resources.path', - return_value=mock_args.sdc_home + 'dummy.jpg') - mock_controller = mocker.patch('securedrop_client.app.Controller') - mocker.patch('securedrop_client.app.prevent_second_instance') - mocker.patch('securedrop_client.app.sys') - mocker.patch('securedrop_client.app.make_session_maker', return_value=mock_session_maker) + mocker.patch("securedrop_client.app.configure_logging") + mock_app = mocker.patch("securedrop_client.app.QApplication") + mock_win = mocker.patch("securedrop_client.app.Window") + mocker.patch("securedrop_client.resources.path", return_value=mock_args.sdc_home + "dummy.jpg") + mock_controller = mocker.patch("securedrop_client.app.Controller") + mocker.patch("securedrop_client.app.prevent_second_instance") + mocker.patch("securedrop_client.app.sys") + mocker.patch("securedrop_client.app.make_session_maker", return_value=mock_session_maker) start_app(mock_args, mock_qt_args) mock_app.assert_called_once_with(mock_qt_args) mock_win.assert_called_once_with() - mock_controller.assert_called_once_with('http://localhost:8081/', - mock_win(), mock_session_maker, - homedir, False, False) + mock_controller.assert_called_once_with( + "http://localhost:8081/", mock_win(), mock_session_maker, homedir, False, False + ) PERMISSIONS_CASES = [ - { - 'should_pass': True, - 'home_perms': None, - 'sub_dirs': [], - }, - { - 'should_pass': True, - 'home_perms': 0o0700, - 'sub_dirs': [], - }, - { - 'should_pass': False, - 'home_perms': 0o0740, - 'sub_dirs': [], - }, - { - 'should_pass': False, - 'home_perms': 0o0704, - 'sub_dirs': [], - }, - { - 'should_pass': True, - 'home_perms': 0o0700, - 'sub_dirs': [('logs', 0o0700)], - }, - { - 'should_pass': False, - 'home_perms': 0o0700, - 'sub_dirs': [('logs', 0o0740)], - }, + {"should_pass": True, "home_perms": None, "sub_dirs": [],}, + {"should_pass": True, "home_perms": 0o0700, "sub_dirs": [],}, + {"should_pass": False, "home_perms": 0o0740, "sub_dirs": [],}, + {"should_pass": False, "home_perms": 0o0704, "sub_dirs": [],}, + {"should_pass": True, "home_perms": 0o0700, "sub_dirs": [("logs", 0o0700)],}, + {"should_pass": False, "home_perms": 0o0700, "sub_dirs": [("logs", 0o0740)],}, ] @@ -177,34 +163,33 @@ def test_create_app_dir_permissions(tmpdir, mocker): for idx, case in enumerate(PERMISSIONS_CASES): mock_session_maker = mocker.MagicMock() mock_args = mocker.MagicMock() - sdc_home = os.path.join(str(tmpdir), 'case-{}'.format(idx)) + sdc_home = os.path.join(str(tmpdir), "case-{}".format(idx)) mock_args.sdc_home = sdc_home mock_qt_args = mocker.MagicMock() # optionally create the dir - if case['home_perms']: - os.mkdir(sdc_home, case['home_perms']) + if case["home_perms"]: + os.mkdir(sdc_home, case["home_perms"]) mock_args.sdc_home = sdc_home - for subdir, perms in case['sub_dirs']: + for subdir, perms in case["sub_dirs"]: full_path = os.path.join(sdc_home, subdir) os.makedirs(full_path, perms) - mocker.patch('logging.getLogger') - mocker.patch('securedrop_client.app.QApplication') - mocker.patch('securedrop_client.app.Window') - mocker.patch('securedrop_client.app.Controller') - mocker.patch('securedrop_client.app.sys') - mocker.patch('securedrop_client.resources.path', - return_value=sdc_home + 'dummy.jpg') - mocker.patch('securedrop_client.app.prevent_second_instance') - mocker.patch('securedrop_client.app.make_session_maker', return_value=mock_session_maker) + mocker.patch("logging.getLogger") + mocker.patch("securedrop_client.app.QApplication") + mocker.patch("securedrop_client.app.Window") + mocker.patch("securedrop_client.app.Controller") + mocker.patch("securedrop_client.app.sys") + mocker.patch("securedrop_client.resources.path", return_value=sdc_home + "dummy.jpg") + mocker.patch("securedrop_client.app.prevent_second_instance") + mocker.patch("securedrop_client.app.make_session_maker", return_value=mock_session_maker) def func(): start_app(mock_args, mock_qt_args) - if case['should_pass']: + if case["should_pass"]: func() else: with pytest.raises(RuntimeError): @@ -217,8 +202,8 @@ def func(): def test_argparse(mocker): parser = arg_parser() - return_value = '/some/path' - mock_expand = mocker.patch('os.path.expanduser', return_value=return_value) + return_value = "/some/path" + mock_expand = mocker.patch("os.path.expanduser", return_value=return_value) args = parser.parse_args([]) # check that the default home is used when no args args supplied @@ -228,7 +213,7 @@ def test_argparse(mocker): def test_main(mocker): - mock_run = mocker.patch('securedrop_client.app.run') + mock_run = mocker.patch("securedrop_client.app.run") import securedrop_client.__main__ # noqa assert mock_run.called @@ -241,23 +226,23 @@ def test_run(mocker): def fake_known_args(): return (mock_args, mock_qt_args) - mock_start_app = mocker.patch('securedrop_client.app.start_app') - mocker.patch('argparse.ArgumentParser.parse_known_args', side_effect=fake_known_args) + mock_start_app = mocker.patch("securedrop_client.app.start_app") + mocker.patch("argparse.ArgumentParser.parse_known_args", side_effect=fake_known_args) run() mock_start_app.assert_called_once_with(mock_args, mock_qt_args) def test_signal_interception(mocker, homedir): # check that initializing an app calls configure_signal_handlers - mocker.patch('securedrop_client.app.QApplication') - mocker.patch('securedrop_client.app.prevent_second_instance') - mocker.patch('sys.exit') - mocker.patch('securedrop_client.db.make_session_maker') - mocker.patch('securedrop_client.app.init') - mocker.patch('securedrop_client.logic.Controller.setup') - mocker.patch('securedrop_client.logic.GpgHelper') - mocker.patch('securedrop_client.app.configure_logging') - mock_signal_handlers = mocker.patch('securedrop_client.app.configure_signal_handlers') + mocker.patch("securedrop_client.app.QApplication") + mocker.patch("securedrop_client.app.prevent_second_instance") + mocker.patch("sys.exit") + mocker.patch("securedrop_client.db.make_session_maker") + mocker.patch("securedrop_client.app.init") + mocker.patch("securedrop_client.logic.Controller.setup") + mocker.patch("securedrop_client.logic.GpgHelper") + mocker.patch("securedrop_client.app.configure_logging") + mock_signal_handlers = mocker.patch("securedrop_client.app.configure_signal_handlers") mock_args = mocker.Mock() mock_args.sdc_home = homedir @@ -266,8 +251,8 @@ def test_signal_interception(mocker, homedir): # check that a signal interception calls quit on the app mock_app = mocker.MagicMock() - mock_quit = mocker.patch.object(mock_app, 'quit') - mock_signal = mocker.patch('signal.signal') + mock_quit = mocker.patch.object(mock_app, "quit") + mock_signal = mocker.patch("signal.signal") configure_signal_handlers(mock_app) assert mock_signal.called diff --git a/tests/test_config.py b/tests/test_config.py index 5f85e9b6a..3073fb416 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,11 +1,12 @@ import os + from securedrop_client.config import Config def test_missing_file(homedir): - ''' + """ If a file doesn't exist, the config can still be created, but is "invalid". - ''' + """ # precondition assert not os.path.exists(os.path.join(homedir, Config.CONFIG_NAME)) @@ -16,12 +17,12 @@ def test_missing_file(homedir): def test_missing_journalist_key_fpr(homedir): - ''' + """ If a key is missing, the config can still be created, but is "invalid". - ''' + """ config_path = os.path.join(homedir, Config.CONFIG_NAME) - with open(config_path, 'w') as f: - f.write('{}') + with open(config_path, "w") as f: + f.write("{}") config = Config.from_home_dir(homedir) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 1ea16550f..f582a4e27 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -5,13 +5,13 @@ import pytest -from securedrop_client.crypto import GpgHelper, CryptoError, read_gzip_header_filename +from securedrop_client.crypto import CryptoError, GpgHelper, read_gzip_header_filename from tests import factory -with open(os.path.join(os.path.dirname(__file__), 'files', 'test-key.gpg.pub.asc')) as f: +with open(os.path.join(os.path.dirname(__file__), "files", "test-key.gpg.pub.asc")) as f: PUB_KEY = f.read() -with open(os.path.join(os.path.dirname(__file__), 'files', 'securedrop.gpg.asc')) as f: +with open(os.path.join(os.path.dirname(__file__), "files", "securedrop.gpg.asc")) as f: JOURNO_KEY = f.read() @@ -22,18 +22,18 @@ def test_message_logic(homedir, config, mocker, session_maker): """ gpg = GpgHelper(homedir, session_maker, is_qubes=False) - test_msg = 'tests/files/test-msg.gpg' - expected_output_filepath = os.path.join(homedir, 'data', 'test-msg') + test_msg = "tests/files/test-msg.gpg" + expected_output_filepath = os.path.join(homedir, "data", "test-msg") - mock_gpg = mocker.patch('subprocess.call', return_value=0) - mocker.patch('os.unlink') + mock_gpg = mocker.patch("subprocess.call", return_value=0) + mocker.patch("os.unlink") original_filename = gpg.decrypt_submission_or_reply( test_msg, expected_output_filepath, is_doc=False ) assert mock_gpg.call_count == 1 - assert original_filename == 'test-msg' + assert original_filename == "test-msg" def test_gunzip_logic(homedir, config, mocker, session_maker): @@ -46,16 +46,16 @@ def test_gunzip_logic(homedir, config, mocker, session_maker): gpg._import(PUB_KEY) gpg._import(JOURNO_KEY) - test_gzip = 'tests/files/test-doc.gz.gpg' - expected_output_filepath = 'tests/files/test-doc.txt' + test_gzip = "tests/files/test-doc.gz.gpg" + expected_output_filepath = "tests/files/test-doc.txt" # mock_gpg = mocker.patch('subprocess.call', return_value=0) - mock_unlink = mocker.patch('os.unlink') + mock_unlink = mocker.patch("os.unlink") original_filename = gpg.decrypt_submission_or_reply( test_gzip, expected_output_filepath, is_doc=True ) - assert original_filename == 'test-doc.txt' + assert original_filename == "test-doc.txt" # We should remove two files in the success scenario: err, filepath assert mock_unlink.call_count == 2 @@ -72,20 +72,20 @@ def test_gzip_header_without_filename(homedir, config, mocker, session_maker): gpg._import(PUB_KEY) gpg._import(JOURNO_KEY) - mocker.patch('os.unlink') - mocker.patch('gzip.open') - mocker.patch('shutil.copy') - mocker.patch('shutil.copyfileobj') + mocker.patch("os.unlink") + mocker.patch("gzip.open") + mocker.patch("shutil.copy") + mocker.patch("shutil.copyfileobj") # pretend the gzipped file header lacked the original filename mock_read_gzip_header_filename = mocker.patch( - 'securedrop_client.crypto.read_gzip_header_filename' + "securedrop_client.crypto.read_gzip_header_filename" ) mock_read_gzip_header_filename.return_value = "" - test_gzip = 'tests/files/test-doc.gz.gpg' - output_filename = 'test-doc' - expected_output_filename = 'tests/files/test-doc' + test_gzip = "tests/files/test-doc.gz.gpg" + output_filename = "test-doc" + expected_output_filename = "tests/files/test-doc" original_filename = gpg.decrypt_submission_or_reply(test_gzip, output_filename, is_doc=True) assert original_filename == output_filename @@ -94,7 +94,7 @@ def test_gzip_header_without_filename(homedir, config, mocker, session_maker): def test_read_gzip_header_filename_with_bad_file(homedir): with tempfile.NamedTemporaryFile() as tf: - tf.write(b'test') + tf.write(b"test") tf.seek(0) with pytest.raises(OSError, match=r"Not a gzipped file"): read_gzip_header_filename(tf.name) @@ -102,7 +102,7 @@ def test_read_gzip_header_filename_with_bad_file(homedir): def test_read_gzip_header_filename_with_bad_compression_method(homedir): # 9 is a bad method - header = struct.pack(' None: - Controller('http://localhost', mock_gui, session_maker, sdc_home) + Controller("http://localhost", mock_gui, session_maker, sdc_home) - if case['should_pass']: + if case["should_pass"]: func() else: with pytest.raises(RuntimeError): @@ -819,15 +791,15 @@ def test_Controller_on_file_download_Submission(homedir, config, session, mocker Using the `config` fixture to ensure the config is written to disk. """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) - co.api = 'this has a value' + co = Controller("http://localhost", mock_gui, session_maker, homedir) + co.api = "this has a value" mock_success_signal = mocker.MagicMock() mock_failure_signal = mocker.MagicMock() - mock_job = mocker.MagicMock(success_signal=mock_success_signal, - failure_signal=mock_failure_signal) - mock_job_cls = mocker.patch( - "securedrop_client.logic.FileDownloadJob", return_value=mock_job) + mock_job = mocker.MagicMock( + success_signal=mock_success_signal, failure_signal=mock_failure_signal + ) + mock_job_cls = mocker.patch("securedrop_client.logic.FileDownloadJob", return_value=mock_job) co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() @@ -840,31 +812,32 @@ def test_Controller_on_file_download_Submission(homedir, config, session, mocker co.on_submission_download(db.File, file_.uuid) mock_job_cls.assert_called_once_with( - file_.uuid, - co.data_dir, - co.gpg, + file_.uuid, co.data_dir, co.gpg, ) co.add_job.emit.assert_called_once_with(mock_job) mock_success_signal.connect.assert_called_once_with( - co.on_file_download_success, type=Qt.QueuedConnection) + co.on_file_download_success, type=Qt.QueuedConnection + ) mock_failure_signal.connect.assert_called_once_with( - co.on_file_download_failure, type=Qt.QueuedConnection) + co.on_file_download_failure, type=Qt.QueuedConnection + ) -def test_Controller_on_file_download_Submission_no_auth(homedir, config, session, - mocker, session_maker): +def test_Controller_on_file_download_Submission_no_auth( + homedir, config, session, mocker, session_maker +): """If the controller is not authenticated, do not enqueue a download job""" mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) co.on_action_requiring_login = mocker.MagicMock() co.api = None mock_success_signal = mocker.MagicMock() mock_failure_signal = mocker.MagicMock() - mock_job = mocker.MagicMock(success_signal=mock_success_signal, - failure_signal=mock_failure_signal) - mock_job_cls = mocker.patch( - "securedrop_client.logic.FileDownloadJob", return_value=mock_job) + mock_job = mocker.MagicMock( + success_signal=mock_success_signal, failure_signal=mock_failure_signal + ) + mock_job_cls = mocker.patch("securedrop_client.logic.FileDownloadJob", return_value=mock_job) co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() @@ -884,16 +857,16 @@ def test_Controller_on_file_download_Submission_no_auth(homedir, config, session def test_Controller_on_file_downloaded_success(homedir, config, mocker, session_maker): - ''' + """ Using the `config` fixture to ensure the config is written to disk. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) co.session = mocker.MagicMock() # signal when file is downloaded - mock_file_ready = mocker.patch.object(co, 'file_ready') + mock_file_ready = mocker.patch.object(co, "file_ready") mock_storage = mocker.MagicMock() mock_file = mocker.MagicMock() @@ -902,22 +875,22 @@ def test_Controller_on_file_downloaded_success(homedir, config, mocker, session_ mock_storage.get_file.return_value = mock_file with mocker.patch("securedrop_client.logic.storage", mock_storage): - co.on_file_download_success('file_uuid') + co.on_file_download_success("file_uuid") - mock_file_ready.emit.assert_called_once_with("a_uuid", 'file_uuid', "foo.txt") + mock_file_ready.emit.assert_called_once_with("a_uuid", "file_uuid", "foo.txt") def test_Controller_on_file_downloaded_api_failure(homedir, config, mocker, session_maker): - ''' + """ Using the `config` fixture to ensure the config is written to disk. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) # signal when file is downloaded - mock_file_ready = mocker.patch.object(co, 'file_ready') - mock_update_error_status = mocker.patch.object(mock_gui, 'update_error_status') - result_data = DownloadException('error message', type(db.File), "test-uuid") + mock_file_ready = mocker.patch.object(co, "file_ready") + mock_update_error_status = mocker.patch.object(mock_gui, "update_error_status") + result_data = DownloadException("error message", type(db.File), "test-uuid") co.on_file_download_failure(result_data) @@ -926,29 +899,29 @@ def test_Controller_on_file_downloaded_api_failure(homedir, config, mocker, sess def test_Controller_on_file_downloaded_checksum_failure(homedir, config, mocker, session_maker): - ''' + """ Check that a failed download due to checksum resubmits the job and informs the user. - ''' + """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) file_ = factory.File(is_downloaded=None, is_decrypted=None, source=factory.Source()) - mock_set_status = mocker.patch.object(co, 'set_status') - mock_file_ready = mocker.patch.object(co, 'file_ready') + mock_set_status = mocker.patch.object(co, "set_status") + mock_file_ready = mocker.patch.object(co, "file_ready") - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") co._submit_download_job = mocker.MagicMock() - co.on_file_download_failure(DownloadChecksumMismatchException('bang!', - type(file_), file_.uuid)) + co.on_file_download_failure(DownloadChecksumMismatchException("bang!", type(file_), file_.uuid)) mock_file_ready.emit.assert_not_called() # Job should get resubmitted and we should log this is happening assert co._submit_download_job.call_count == 1 - warning_logger.call_args_list[0][0][0] == \ - 'Failure due to checksum mismatch, retrying {}'.format(file_.uuid) + warning_logger.call_args_list[0][0][ + 0 + ] == "Failure due to checksum mismatch, retrying {}".format(file_.uuid) # No status will be set if it's a file corruption issue, the file just gets # re-downloaded. @@ -956,32 +929,31 @@ def test_Controller_on_file_downloaded_checksum_failure(homedir, config, mocker, def test_Controller_on_file_decryption_failure(homedir, config, mocker, session, session_maker): - ''' + """ Check handling of a download decryption failure. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) file_ = factory.File(is_downloaded=True, is_decrypted=False, source=factory.Source()) session.add(file_) session.commit() - mock_set_status = mocker.patch.object(co, 'set_status') - mock_file_ready = mocker.patch.object(co, 'file_ready') - mock_update_error_status = mocker.patch.object(mock_gui, 'update_error_status') + mock_set_status = mocker.patch.object(co, "set_status") + mock_file_ready = mocker.patch.object(co, "file_ready") + mock_update_error_status = mocker.patch.object(mock_gui, "update_error_status") - error_logger = mocker.patch('securedrop_client.logic.logger.error') + error_logger = mocker.patch("securedrop_client.logic.logger.error") co._submit_download_job = mocker.MagicMock() - co.on_file_download_failure(DownloadDecryptionException('bang!', type(file_), file_.uuid)) + co.on_file_download_failure(DownloadDecryptionException("bang!", type(file_), file_.uuid)) mock_file_ready.emit.assert_not_called() mock_update_error_status.assert_called_once_with("The file download failed. Please try again.") co._submit_download_job.call_count == 1 - error_logger.call_args_list[0][0][0] == \ - 'Failed to decrypt {}'.format(file_.uuid) + error_logger.call_args_list[0][0][0] == "Failed to decrypt {}".format(file_.uuid) mock_set_status.assert_not_called() @@ -993,18 +965,18 @@ def test_Controller_on_file_open(homedir, config, mocker, session, session_maker Using the `config` fixture to ensure the config is written to disk. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = True - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() mock_subprocess = mocker.MagicMock() mock_process = mocker.MagicMock(return_value=mock_subprocess) - mocker.patch('securedrop_client.logic.QProcess', mock_process) + mocker.patch("securedrop_client.logic.QProcess", mock_process) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath)) - with open(filepath, 'w'): + with open(filepath, "w"): pass co.on_file_open(file) @@ -1017,15 +989,15 @@ def test_Controller_on_file_open_not_qubes(homedir, config, mocker, session, ses """ Check that we just check if the file exists if not running on Qubes. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = False - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath)) - with open(filepath, 'w'): + with open(filepath, "w"): pass co.on_file_open(file) @@ -1040,19 +1012,19 @@ def test_Controller_on_file_open_when_orig_file_already_exists( Using the `config` fixture to ensure the config is written to disk. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = True - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) mock_subprocess = mocker.MagicMock() mock_process = mocker.MagicMock(return_value=mock_subprocess) - mocker.patch('securedrop_client.logic.QProcess', mock_process) + mocker.patch("securedrop_client.logic.QProcess", mock_process) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass co.on_file_open(file) @@ -1067,16 +1039,16 @@ def test_Controller_on_file_open_when_orig_file_already_exists_not_qubes( """ Check that we just check if the file exists if not running on Qubes. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = False - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass co.on_file_open(file) @@ -1086,17 +1058,16 @@ def test_Controller_on_file_open_file_missing(mocker, homedir, session_maker, se """ When file does not exist, test that we log and send status update to user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - file = factory.File(source=source['source']) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + file = factory.File(source=source["source"]) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") co.on_file_open(file) - log_msg = 'Cannot find file in {}. File does not exist.'.format( - os.path.dirname(file.filename)) + log_msg = "Cannot find file in {}. File does not exist.".format(os.path.dirname(file.filename)) warning_logger.assert_called_once_with(log_msg) @@ -1106,18 +1077,17 @@ def test_Controller_on_file_open_file_missing_not_qubes( """ When file does not exist on a non-qubes system, test that we log and send status update to user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = False - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") co.on_file_open(file) - log_msg = 'Cannot find file in {}. File does not exist.'.format( - os.path.dirname(file.filename)) + log_msg = "Cannot find file in {}. File does not exist.".format(os.path.dirname(file.filename)) warning_logger.assert_called_once_with(log_msg) @@ -1126,10 +1096,10 @@ def test_Controller_download_new_replies_with_new_reply(mocker, session, session Test that `download_new_replies` enqueues a job, connects to the right slots, and sets a user-facing status message when a new reply is found. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - co.api = 'Api token has a value' + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.api = "Api token has a value" reply = factory.Reply(source=factory.Source()) - mocker.patch('securedrop_client.storage.find_new_replies', return_value=[reply]) + mocker.patch("securedrop_client.storage.find_new_replies", return_value=[reply]) success_signal = mocker.MagicMock() failure_signal = mocker.MagicMock() job = mocker.MagicMock(success_signal=success_signal, failure_signal=failure_signal) @@ -1141,9 +1111,11 @@ def test_Controller_download_new_replies_with_new_reply(mocker, session, session co.add_job.emit.assert_called_once_with(job) success_signal.connect.assert_called_once_with( - co.on_reply_download_success, type=Qt.QueuedConnection) + co.on_reply_download_success, type=Qt.QueuedConnection + ) failure_signal.connect.assert_called_once_with( - co.on_reply_download_failure, type=Qt.QueuedConnection) + co.on_reply_download_failure, type=Qt.QueuedConnection + ) def test_Controller_download_new_replies_without_replies(mocker, session, session_maker, homedir): @@ -1151,15 +1123,15 @@ def test_Controller_download_new_replies_without_replies(mocker, session, sessio Test that `download_new_replies` does not enqueue any jobs or connect to slots or set a user-facing status message when there are no new replies found. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - mocker.patch('securedrop_client.storage.find_new_replies', return_value=[]) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + mocker.patch("securedrop_client.storage.find_new_replies", return_value=[]) success_signal = mocker.MagicMock() failure_signal = mocker.MagicMock() job = mocker.MagicMock(success_signal=success_signal, failure_signal=failure_signal) mocker.patch("securedrop_client.logic.ReplyDownloadJob", return_value=job) co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() - set_status = mocker.patch.object(co, 'set_status') + set_status = mocker.patch.object(co, "set_status") co.download_new_replies() @@ -1173,10 +1145,10 @@ def test_Controller_on_reply_downloaded_success(mocker, homedir, session_maker): """ Check that a successful download emits proper signal. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_ready = mocker.patch.object(co, 'reply_ready') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_ready = mocker.patch.object(co, "reply_ready") reply = factory.Message(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_reply', return_value=reply) + mocker.patch("securedrop_client.storage.get_reply", return_value=reply) co.on_reply_download_success(reply.uuid) @@ -1187,13 +1159,13 @@ def test_Controller_on_reply_downloaded_failure(mocker, homedir, session_maker): """ Check that a failed download informs the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_ready = mocker.patch.object(co, 'reply_ready') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_ready = mocker.patch.object(co, "reply_ready") reply = factory.Reply(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_reply', return_value=reply) + mocker.patch("securedrop_client.storage.get_reply", return_value=reply) co._submit_download_job = mocker.MagicMock() - co.on_reply_download_failure(Exception('mock_exception')) + co.on_reply_download_failure(Exception("mock_exception")) reply_ready.emit.assert_not_called() @@ -1205,35 +1177,37 @@ def test_Controller_on_reply_downloaded_checksum_failure(mocker, homedir, sessio """ Check that a failed download due to checksum resubmits the job and informs the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_ready = mocker.patch.object(co, 'reply_ready') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_ready = mocker.patch.object(co, "reply_ready") reply = factory.Reply(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_reply', return_value=reply) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.storage.get_reply", return_value=reply) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") co._submit_download_job = mocker.MagicMock() - co.on_reply_download_failure(DownloadChecksumMismatchException('bang!', - type(reply), reply.uuid)) + co.on_reply_download_failure( + DownloadChecksumMismatchException("bang!", type(reply), reply.uuid) + ) reply_ready.emit.assert_not_called() # Job should get resubmitted and we should log this is happening co._submit_download_job.call_count == 1 - warning_logger.call_args_list[0][0][0] == \ - 'Failure due to checksum mismatch, retrying {}'.format(reply.uuid) + warning_logger.call_args_list[0][0][ + 0 + ] == "Failure due to checksum mismatch, retrying {}".format(reply.uuid) def test_Controller_on_reply_downloaded_decryption_failure(mocker, homedir, session_maker): """ Check that a failed download due to a decryption error informs the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_ready = mocker.patch.object(co, 'reply_ready') - reply_download_failed = mocker.patch.object(co, 'reply_download_failed') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_ready = mocker.patch.object(co, "reply_ready") + reply_download_failed = mocker.patch.object(co, "reply_download_failed") reply = factory.Reply(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_reply', return_value=reply) + mocker.patch("securedrop_client.storage.get_reply", return_value=reply) - decryption_exception = DownloadDecryptionException('bang!', type(reply), reply.uuid) + decryption_exception = DownloadDecryptionException("bang!", type(reply), reply.uuid) co.on_reply_download_failure(decryption_exception) reply_ready.emit.assert_not_called() @@ -1245,26 +1219,28 @@ def test_Controller_download_new_messages_with_new_message(mocker, session, sess Test that `download_new_messages` enqueues a job, connects to the right slots, and sets a usre-facing status message when a new message is found. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - co.api = 'Api token has a value' + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.api = "Api token has a value" message = factory.Message(source=factory.Source()) - mocker.patch('securedrop_client.storage.find_new_messages', return_value=[message]) + mocker.patch("securedrop_client.storage.find_new_messages", return_value=[message]) success_signal = mocker.MagicMock() failure_signal = mocker.MagicMock() co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() job = mocker.MagicMock(success_signal=success_signal, failure_signal=failure_signal) mocker.patch("securedrop_client.logic.MessageDownloadJob", return_value=job) - set_status = mocker.patch.object(co, 'set_status') + set_status = mocker.patch.object(co, "set_status") co.download_new_messages() co.add_job.emit.assert_called_once_with(job) success_signal.connect.assert_called_once_with( - co.on_message_download_success, type=Qt.QueuedConnection) + co.on_message_download_success, type=Qt.QueuedConnection + ) failure_signal.connect.assert_called_once_with( - co.on_message_download_failure, type=Qt.QueuedConnection) - set_status.assert_called_once_with('Retrieving new messages', 2500) + co.on_message_download_failure, type=Qt.QueuedConnection + ) + set_status.assert_called_once_with("Retrieving new messages", 2500) def test_Controller_download_new_messages_without_messages(mocker, session, session_maker, homedir): @@ -1272,15 +1248,15 @@ def test_Controller_download_new_messages_without_messages(mocker, session, sess Test that `download_new_messages` does not enqueue any jobs or connect to slots or set a user-facing status message when there are no new messages found. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - mocker.patch('securedrop_client.storage.find_new_messages', return_value=[]) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + mocker.patch("securedrop_client.storage.find_new_messages", return_value=[]) success_signal = mocker.MagicMock() failure_signal = mocker.MagicMock() job = mocker.MagicMock(success_signal=success_signal, failure_signal=failure_signal) mocker.patch("securedrop_client.logic.MessageDownloadJob", return_value=job) co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() - set_status = mocker.patch.object(co, 'set_status') + set_status = mocker.patch.object(co, "set_status") co.download_new_messages() @@ -1291,7 +1267,7 @@ def test_Controller_download_new_messages_without_messages(mocker, session, sess def test_Controller_download_new_messages_skips_recent_failures( - mocker, session, session_maker, homedir, download_error_codes + mocker, session, session_maker, homedir, download_error_codes ): """ Test that `download_new_messages` skips recently failed downloads. @@ -1302,9 +1278,11 @@ def test_Controller_download_new_messages_skips_recent_failures( co.add_job.emit = mocker.MagicMock() # record the download failures - download_error = session.query(db.DownloadError).filter_by( - name=db.DownloadErrorCodes.DECRYPTION_ERROR.name - ).one() + download_error = ( + session.query(db.DownloadError) + .filter_by(name=db.DownloadErrorCodes.DECRYPTION_ERROR.name) + .one() + ) message = factory.Message(source=factory.Source()) message.download_error = download_error @@ -1323,7 +1301,7 @@ def test_Controller_download_new_messages_skips_recent_failures( def test_Controller_download_new_replies_skips_recent_failures( - mocker, session, session_maker, homedir, download_error_codes + mocker, session, session_maker, homedir, download_error_codes ): """ Test that `download_new_replies` skips recently failed downloads. @@ -1334,9 +1312,11 @@ def test_Controller_download_new_replies_skips_recent_failures( co.add_job.emit = mocker.MagicMock() # record the download failures - download_error = session.query(db.DownloadError).filter_by( - name=db.DownloadErrorCodes.DECRYPTION_ERROR.name - ).one() + download_error = ( + session.query(db.DownloadError) + .filter_by(name=db.DownloadErrorCodes.DECRYPTION_ERROR.name) + .one() + ) reply = factory.Reply(source=factory.Source()) reply.download_error = download_error @@ -1359,10 +1339,10 @@ def test_Controller_on_message_downloaded_success(mocker, homedir, session_maker """ Check that a successful download emits proper signal. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - message_ready = mocker.patch.object(co, 'message_ready') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + message_ready = mocker.patch.object(co, "message_ready") message = factory.Message(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_message', return_value=message) + mocker.patch("securedrop_client.storage.get_message", return_value=message) co.on_message_download_success(message.uuid) @@ -1373,13 +1353,13 @@ def test_Controller_on_message_downloaded_failure(mocker, homedir, session_maker """ Check that a failed download informs the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - message_ready = mocker.patch.object(co, 'message_ready') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + message_ready = mocker.patch.object(co, "message_ready") message = factory.Message(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_message', return_value=message) + mocker.patch("securedrop_client.storage.get_message", return_value=message) co._submit_download_job = mocker.MagicMock() - co.on_message_download_failure(Exception('mock_exception')) + co.on_message_download_failure(Exception("mock_exception")) message_ready.emit.assert_not_called() @@ -1391,14 +1371,15 @@ def test_Controller_on_message_downloaded_checksum_failure(mocker, homedir, sess """ Check that a failed download due to checksum resubmits the job and informs the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - message_ready = mocker.patch.object(co, 'message_ready') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + message_ready = mocker.patch.object(co, "message_ready") message = factory.Message(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_message', return_value=message) + mocker.patch("securedrop_client.storage.get_message", return_value=message) co._submit_download_job = mocker.MagicMock() - co.on_message_download_failure(DownloadChecksumMismatchException('bang!', - type(message), message.uuid)) + co.on_message_download_failure( + DownloadChecksumMismatchException("bang!", type(message), message.uuid) + ) message_ready.emit.assert_not_called() @@ -1410,13 +1391,13 @@ def test_Controller_on_message_downloaded_decryption_failure(mocker, homedir, se """ Check that a failed download due to a decryption error informs the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - message_ready = mocker.patch.object(co, 'message_ready') - message_download_failed = mocker.patch.object(co, 'message_download_failed') + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + message_ready = mocker.patch.object(co, "message_ready") + message_download_failed = mocker.patch.object(co, "message_download_failed") message = factory.Message(source=factory.Source()) - mocker.patch('securedrop_client.storage.get_message', return_value=message) + mocker.patch("securedrop_client.storage.get_message", return_value=message) - decryption_exception = DownloadDecryptionException('bang!', type(message), message.uuid) + decryption_exception = DownloadDecryptionException("bang!", type(message), message.uuid) co.on_message_download_failure(decryption_exception) message_ready.emit.assert_not_called() @@ -1424,26 +1405,26 @@ def test_Controller_on_message_downloaded_decryption_failure(mocker, homedir, se def test_Controller_on_delete_source_success(mocker, homedir): - ''' + """ Test that on a successful deletion does not delete the source locally (regression). - ''' - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + """ + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.source_deleted = mocker.MagicMock() - storage = mocker.patch('securedrop_client.logic.storage') + storage = mocker.patch("securedrop_client.logic.storage") - co.on_delete_source_success('uuid') + co.on_delete_source_success("uuid") storage.delete_local_source_by_uuid.assert_not_called() def test_Controller_on_delete_source_failure(homedir, config, mocker, session_maker): - ''' + """ Using the `config` fixture to ensure the config is written to disk. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) - co.on_delete_source_failure(DeleteSourceJobException('weow', 'uuid')) - co.gui.update_error_status.assert_called_with('Failed to delete source at server') + co = Controller("http://localhost", mock_gui, session_maker, homedir) + co.on_delete_source_failure(DeleteSourceJobException("weow", "uuid")) + co.gui.update_error_status.assert_called_with("Failed to delete source at server") def test_Controller_delete_source_not_logged_in(homedir, config, mocker, session_maker): @@ -1454,7 +1435,7 @@ def test_Controller_delete_source_not_logged_in(homedir, config, mocker, session method that displays an error status in the left sidebar. """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) source_db_object = mocker.MagicMock() co.on_action_requiring_login = mocker.MagicMock() co.api = None @@ -1463,11 +1444,11 @@ def test_Controller_delete_source_not_logged_in(homedir, config, mocker, session def test_Controller_delete_source(homedir, config, mocker, session_maker, session): - ''' + """ Check that a DeleteSourceJob is submitted when delete_source is called. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) co.call_api = mocker.MagicMock() co.api = mocker.MagicMock() co.source_deleted = mocker.MagicMock() @@ -1477,7 +1458,8 @@ def test_Controller_delete_source(homedir, config, mocker, session_maker, sessio mock_success_signal = mocker.MagicMock() mock_failure_signal = mocker.MagicMock() mock_job = mocker.MagicMock( - success_signal=mock_success_signal, failure_signal=mock_failure_signal) + success_signal=mock_success_signal, failure_signal=mock_failure_signal + ) mock_job_cls = mocker.patch("securedrop_client.logic.DeleteSourceJob", return_value=mock_job) source = factory.Source() @@ -1490,28 +1472,31 @@ def test_Controller_delete_source(homedir, config, mocker, session_maker, sessio mock_job_cls.assert_called_once_with(source.uuid) co.add_job.emit.assert_called_once_with(mock_job) mock_success_signal.connect.assert_called_once_with( - co.on_delete_source_success, type=Qt.QueuedConnection) + co.on_delete_source_success, type=Qt.QueuedConnection + ) mock_failure_signal.connect.assert_called_once_with( - co.on_delete_source_failure, type=Qt.QueuedConnection) + co.on_delete_source_failure, type=Qt.QueuedConnection + ) -def test_Controller_send_reply_success(homedir, config, mocker, session_maker, session, - reply_status_codes): - ''' +def test_Controller_send_reply_success( + homedir, config, mocker, session_maker, session, reply_status_codes +): + """ Check that a SendReplyJob is submitted to the queue when send_reply is called. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) co.user = factory.User() co.api = mocker.MagicMock() co.api.token_journalist_uuid = co.user.uuid mock_success_signal = mocker.MagicMock() mock_failure_signal = mocker.MagicMock() - mock_job = mocker.MagicMock(success_signal=mock_success_signal, - failure_signal=mock_failure_signal) - mock_job_cls = mocker.patch( - "securedrop_client.logic.SendReplyJob", return_value=mock_job) + mock_job = mocker.MagicMock( + success_signal=mock_success_signal, failure_signal=mock_failure_signal + ) + mock_job_cls = mocker.patch("securedrop_client.logic.SendReplyJob", return_value=mock_job) co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() @@ -1519,31 +1504,30 @@ def test_Controller_send_reply_success(homedir, config, mocker, session_maker, s session.add(source) session.commit() - co.send_reply(source.uuid, 'mock_user_uuid', 'mock_msg') + co.send_reply(source.uuid, "mock_user_uuid", "mock_msg") mock_job_cls.assert_called_once_with( - source.uuid, - 'mock_user_uuid', - 'mock_msg', - co.gpg, + source.uuid, "mock_user_uuid", "mock_msg", co.gpg, ) co.add_job.emit.assert_called_once_with(mock_job) mock_success_signal.connect.assert_called_once_with( - co.on_reply_success, type=Qt.QueuedConnection) + co.on_reply_success, type=Qt.QueuedConnection + ) mock_failure_signal.connect.assert_called_once_with( - co.on_reply_failure, type=Qt.QueuedConnection) + co.on_reply_failure, type=Qt.QueuedConnection + ) def test_Controller_on_reply_success(homedir, mocker, session_maker, session): - ''' + """ Check that when the method is called, the client emits the correct signal. - ''' - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_succeeded = mocker.patch.object(co, 'reply_succeeded') - reply_failed = mocker.patch.object(co, 'reply_failed') + """ + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_succeeded = mocker.patch.object(co, "reply_succeeded") + reply_failed = mocker.patch.object(co, "reply_failed") reply = factory.Reply(source=factory.Source()) - info_logger = mocker.patch('securedrop_client.logic.logger.info') + info_logger = mocker.patch("securedrop_client.logic.logger.info") mock_storage = mocker.MagicMock() mock_reply = mocker.MagicMock() @@ -1554,56 +1538,56 @@ def test_Controller_on_reply_success(homedir, mocker, session_maker, session): with mocker.patch("securedrop_client.logic.storage", mock_storage): co.on_reply_success(reply.uuid) - assert info_logger.call_args_list[0][0][0] == '{} sent successfully'.format(reply.uuid) + assert info_logger.call_args_list[0][0][0] == "{} sent successfully".format(reply.uuid) reply_succeeded.emit.assert_called_once_with("source_uuid", reply.uuid, "reply_message_mock") reply_failed.emit.assert_not_called() def test_Controller_on_reply_failure(homedir, mocker, session_maker): - ''' + """ Check that when the method is called, the client emits the correct signal. - ''' - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_succeeded = mocker.patch.object(co, 'reply_succeeded') - reply_failed = mocker.patch.object(co, 'reply_failed') - debug_logger = mocker.patch('securedrop_client.logic.logger.debug') + """ + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_succeeded = mocker.patch.object(co, "reply_succeeded") + reply_failed = mocker.patch.object(co, "reply_failed") + debug_logger = mocker.patch("securedrop_client.logic.logger.debug") - exception = SendReplyJobError('mock_error_message', 'mock_reply_uuid') + exception = SendReplyJobError("mock_error_message", "mock_reply_uuid") co.on_reply_failure(exception) - debug_logger.assert_called_once_with('{} failed to send'.format('mock_reply_uuid')) - reply_failed.emit.assert_called_once_with('mock_reply_uuid') + debug_logger.assert_called_once_with("{} failed to send".format("mock_reply_uuid")) + reply_failed.emit.assert_called_once_with("mock_reply_uuid") reply_succeeded.emit.assert_not_called() def test_Controller_on_reply_failure_for_timeout(homedir, mocker, session_maker): - ''' + """ Check that when the method is called, the client emits the correct signal. - ''' - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) - reply_succeeded = mocker.patch.object(co, 'reply_succeeded') - reply_failed = mocker.patch.object(co, 'reply_failed') - debug_logger = mocker.patch('securedrop_client.logic.logger.debug') + """ + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + reply_succeeded = mocker.patch.object(co, "reply_succeeded") + reply_failed = mocker.patch.object(co, "reply_failed") + debug_logger = mocker.patch("securedrop_client.logic.logger.debug") - exception = SendReplyJobTimeoutError('mock_error_message', 'mock_reply_uuid') + exception = SendReplyJobTimeoutError("mock_error_message", "mock_reply_uuid") co.on_reply_failure(exception) - debug_logger.assert_called_once_with('{} failed to send'.format('mock_reply_uuid')) + debug_logger.assert_called_once_with("{} failed to send".format("mock_reply_uuid")) reply_failed.emit.assert_not_called() reply_succeeded.emit.assert_not_called() def test_Controller_is_authenticated_property(homedir, mocker, session_maker): - ''' + """ Check that the @property `is_authenticated`: - Cannot be deleted - Emits the correct signals when updated - Sets internal state to ensure signals are only set when the state changes - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) - mock_signal = mocker.patch.object(co, 'authentication_state') + co = Controller("http://localhost", mock_gui, session_maker, homedir) + mock_signal = mocker.patch.object(co, "authentication_state") # default state is unauthenticated assert co.is_authenticated is False @@ -1630,7 +1614,7 @@ def test_Controller_is_authenticated_property(homedir, mocker, session_maker): def test_Controller_resume_queues(homedir, mocker, session_maker): - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.api_job_queue = mocker.MagicMock() co.show_last_sync_timer = mocker.MagicMock() co.resume_queues() @@ -1649,8 +1633,8 @@ def test_APICallRunner_api_call_timeout(mocker, exception): runner = APICallRunner(mock_api.fake_request) - mock_failure_signal = mocker.patch.object(runner, 'call_failed') - mock_timeout_signal = mocker.patch.object(runner, 'call_timed_out') + mock_failure_signal = mocker.patch.object(runner, "call_failed") + mock_timeout_signal = mocker.patch.object(runner, "call_timed_out") runner.call_api() @@ -1660,33 +1644,35 @@ def test_APICallRunner_api_call_timeout(mocker, exception): def test_Controller_on_queue_paused(homedir, config, mocker, session_maker): - ''' + """ Check that a paused queue is communicated to the user via the error status bar - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) - mocker.patch.object(co, 'api_job_queue') - co.api = 'not none' + co = Controller("http://localhost", mock_gui, session_maker, homedir) + mocker.patch.object(co, "api_job_queue") + co.api = "not none" co.show_last_sync_timer = mocker.MagicMock() co.on_queue_paused() mock_gui.update_error_status.assert_called_once_with( - 'The SecureDrop server cannot be reached. Trying to reconnect...', duration=0) + "The SecureDrop server cannot be reached. Trying to reconnect...", duration=0 + ) co.show_last_sync_timer.start.assert_called_once_with(TIME_BETWEEN_SHOWING_LAST_SYNC_MS) def test_Controller_call_update_star_success(homedir, config, mocker, session_maker, session): - ''' + """ Check that a UpdateStar is submitted to the queue when update_star is called. - ''' + """ mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller("http://localhost", mock_gui, session_maker, homedir) co.call_api = mocker.MagicMock() co.api = mocker.MagicMock() star_update_successful = mocker.MagicMock() star_update_failed = mocker.MagicMock() - mock_job = mocker.MagicMock(success_signal=star_update_successful, - failure_signal=star_update_failed) + mock_job = mocker.MagicMock( + success_signal=star_update_successful, failure_signal=star_update_failed + ) mock_job_cls = mocker.patch("securedrop_client.logic.UpdateStarJob", return_value=mock_job) co.add_job = mocker.MagicMock() co.add_job.emit = mocker.MagicMock() @@ -1701,13 +1687,15 @@ def test_Controller_call_update_star_success(homedir, config, mocker, session_ma co.add_job.emit.assert_called_once_with(mock_job) assert star_update_successful.connect.call_count == 1 star_update_failed.connect.assert_called_once_with( - co.on_update_star_failure, type=Qt.QueuedConnection) + co.on_update_star_failure, type=Qt.QueuedConnection + ) star_update_successful.connect.assert_called_once_with( - co.on_update_star_success, type=Qt.QueuedConnection) + co.on_update_star_success, type=Qt.QueuedConnection + ) def test_Controller_run_printer_preflight_checks(homedir, mocker, session, source): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_printer_preflight = mocker.MagicMock() co.export.begin_printer_preflight.emit = mocker.MagicMock() @@ -1718,7 +1706,7 @@ def test_Controller_run_printer_preflight_checks(homedir, mocker, session, sourc def test_Controller_run_printer_preflight_checks_not_qubes(homedir, mocker, session, source): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_printer_preflight = mocker.MagicMock() @@ -1733,17 +1721,17 @@ def test_Controller_run_printer_preflight_checks_not_qubes(homedir, mocker, sess def test_Controller_run_print_file(mocker, session, homedir): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_print.emit = mocker.MagicMock() file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass co.print_file(file.uuid) @@ -1752,7 +1740,7 @@ def test_Controller_run_print_file(mocker, session, homedir): def test_Controller_run_print_file_not_qubes(mocker, session, homedir): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_print = mocker.MagicMock() @@ -1760,11 +1748,11 @@ def test_Controller_run_print_file_not_qubes(mocker, session, homedir): file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass co.print_file(file.uuid) @@ -1777,38 +1765,35 @@ def test_Controller_print_file_file_missing(homedir, mocker, session, session_ma If the file is missing from the data dir, is_downloaded should be set to False and the failure should be communicated to the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") co.print_file(file.uuid) - log_msg = 'Cannot find file in {}. File does not exist.'.format(os.path.dirname(file.filename)) + log_msg = "Cannot find file in {}. File does not exist.".format(os.path.dirname(file.filename)) warning_logger.assert_called_once_with(log_msg) -def test_Controller_print_file_file_missing_not_qubes( - homedir, mocker, session, session_maker -): +def test_Controller_print_file_file_missing_not_qubes(homedir, mocker, session, session_maker): """ If the file is missing from the data dir, is_downloaded should be set to False and the failure should be communicated to the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = False file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") co.print_file(file.uuid) - log_msg = 'Cannot find file in {}. File does not exist.'.format( - os.path.dirname(file.filename)) + log_msg = "Cannot find file in {}. File does not exist.".format(os.path.dirname(file.filename)) warning_logger.assert_called_once_with(log_msg) @@ -1818,15 +1803,15 @@ def test_Controller_print_file_when_orig_file_already_exists( """ The signal `begin_print` should still be emmited if the original file already exists. """ - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_print = mocker.MagicMock() co.export.begin_print.emit = mocker.MagicMock() file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - mocker.patch('os.path.exists', return_value=True) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + mocker.patch("os.path.exists", return_value=True) co.print_file(file.uuid) @@ -1840,7 +1825,7 @@ def test_Controller_print_file_when_orig_file_already_exists_not_qubes( """ The signal `begin_print` should still be emmited if the original file already exists. """ - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_print = mocker.MagicMock() @@ -1848,28 +1833,28 @@ def test_Controller_print_file_when_orig_file_already_exists_not_qubes( file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") co.export.begin_print.emit.call_count == 1 co.get_file.assert_called_with(file.uuid) def test_Controller_run_export_preflight_checks(homedir, mocker, session, source): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_preflight_check = mocker.MagicMock() co.export.begin_preflight_check.emit = mocker.MagicMock() - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) co.run_export_preflight_checks() @@ -1877,15 +1862,15 @@ def test_Controller_run_export_preflight_checks(homedir, mocker, session, source def test_Controller_run_export_preflight_checks_not_qubes(homedir, mocker, session, source): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_preflight_check = mocker.MagicMock() co.export.begin_preflight_check.emit = mocker.MagicMock() - file = factory.File(source=source['source']) + file = factory.File(source=source["source"]) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) co.run_export_preflight_checks() @@ -1896,21 +1881,21 @@ def test_Controller_export_file_to_usb_drive(homedir, mocker, session): """ The signal `begin_usb_export` should be emmited during export_file_to_usb_drive. """ - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_usb_export = mocker.MagicMock() co.export.begin_usb_export.emit = mocker.MagicMock() file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") co.export.begin_usb_export.emit.call_count == 1 @@ -1919,7 +1904,7 @@ def test_Controller_export_file_to_usb_drive_not_qubes(homedir, mocker, session) """ The signal `begin_usb_export` should be emmited during export_file_to_usb_drive. """ - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_usb_export = mocker.MagicMock() @@ -1927,14 +1912,14 @@ def test_Controller_export_file_to_usb_drive_not_qubes(homedir, mocker, session) file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") co.export.send_file_to_usb_device.assert_not_called() co.export.begin_usb_export.emit.call_count == 0 @@ -1945,17 +1930,16 @@ def test_Controller_export_file_to_usb_drive_file_missing(homedir, mocker, sessi If the file is missing from the data dir, is_downloaded should be set to False and the failure should be communicated to the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") - log_msg = 'Cannot find file in {}. File does not exist.'.format( - os.path.dirname(file.filename)) + log_msg = "Cannot find file in {}. File does not exist.".format(os.path.dirname(file.filename)) warning_logger.assert_called_once_with(log_msg) @@ -1966,18 +1950,17 @@ def test_Controller_export_file_to_usb_drive_file_missing_not_qubes( If the file is missing from the data dir, is_downloaded should be set to False and the failure should be communicated to the user. """ - co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.qubes = False file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - warning_logger = mocker.patch('securedrop_client.logic.logger.warning') + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + warning_logger = mocker.patch("securedrop_client.logic.logger.warning") - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") - log_msg = 'Cannot find file in {}. File does not exist.'.format( - os.path.dirname(file.filename)) + log_msg = "Cannot find file in {}. File does not exist.".format(os.path.dirname(file.filename)) warning_logger.assert_called_once_with(log_msg) @@ -1987,17 +1970,17 @@ def test_Controller_export_file_to_usb_drive_when_orig_file_already_exists( """ The signal `begin_usb_export` should still be emmited if the original file already exists. """ - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_usb_export = mocker.MagicMock() co.export.begin_usb_export.emit = mocker.MagicMock() file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) - mocker.patch('os.path.exists', return_value=True) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) + mocker.patch("os.path.exists", return_value=True) - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") co.export.begin_usb_export.emit.call_count == 1 co.get_file.assert_called_with(file.uuid) @@ -2009,7 +1992,7 @@ def test_Controller_export_file_to_usb_drive_when_orig_file_already_exists_not_q """ The signal `begin_usb_export` should still be emmited if the original file already exists. """ - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_usb_export = mocker.MagicMock() @@ -2017,22 +2000,22 @@ def test_Controller_export_file_to_usb_drive_when_orig_file_already_exists_not_q file = factory.File(source=factory.Source()) session.add(file) session.commit() - mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) filepath = file.location(co.data_dir) os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, 'w'): + with open(filepath, "w"): pass - co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + co.export_file_to_usb_drive(file.uuid, "mock passphrase") co.export.begin_usb_export.emit.call_count == 1 co.get_file.assert_called_with(file.uuid) def test_get_file(mocker, session, homedir): - co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) - storage = mocker.patch('securedrop_client.logic.storage') + co = Controller("http://localhost", mocker.MagicMock(), mocker.MagicMock(), homedir) + storage = mocker.patch("securedrop_client.logic.storage") file = factory.File(source=factory.Source()) session.add(file) session.commit() diff --git a/tests/test_models.py b/tests/test_models.py index 520505c72..bd2b185b4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,49 +1,57 @@ import datetime + import pytest -from tests import factory from securedrop_client.db import ( - DownloadError, DownloadErrorCodes, DraftReply, Reply, File, Message, ReplySendStatus, User + DownloadError, + DownloadErrorCodes, + DraftReply, + File, + Message, + Reply, + ReplySendStatus, + User, ) +from tests import factory def test_user_fullname(): - user1 = User(username='username_mock', firstname='firstname_mock', lastname='lastname_mock') - user2 = User(username='username_mock', firstname='firstname_mock') - user3 = User(username='username_mock', lastname='lastname_mock') - user4 = User(username='username_mock') - assert user1.fullname == 'firstname_mock lastname_mock' - assert user2.fullname == 'firstname_mock' - assert user3.fullname == 'lastname_mock' - assert user4.fullname == 'username_mock' + user1 = User(username="username_mock", firstname="firstname_mock", lastname="lastname_mock") + user2 = User(username="username_mock", firstname="firstname_mock") + user3 = User(username="username_mock", lastname="lastname_mock") + user4 = User(username="username_mock") + assert user1.fullname == "firstname_mock lastname_mock" + assert user2.fullname == "firstname_mock" + assert user3.fullname == "lastname_mock" + assert user4.fullname == "username_mock" user1.__repr__() def test_user_initials(): # initials should be first char of firstname followed by first char of last name - user1 = User(username='username_mock', firstname='firstname_mock', lastname='lastname_mock') - user2 = User(username='username_mock', firstname='firstname_mock', lastname='l') - user3 = User(username='username_mock', firstname='f', lastname='lastname_mock') - user4 = User(username='username_mock', firstname='f', lastname='l') - assert user1.initials == 'fl' - assert user2.initials == 'fl' - assert user3.initials == 'fl' - assert user4.initials == 'fl' + user1 = User(username="username_mock", firstname="firstname_mock", lastname="lastname_mock") + user2 = User(username="username_mock", firstname="firstname_mock", lastname="l") + user3 = User(username="username_mock", firstname="f", lastname="lastname_mock") + user4 = User(username="username_mock", firstname="f", lastname="l") + assert user1.initials == "fl" + assert user2.initials == "fl" + assert user3.initials == "fl" + assert user4.initials == "fl" # initials should be first two chars of username - user5 = User(username='username_mock') - user6 = User(username='username_mock', firstname='f') - user7 = User(username='username_mock', lastname='l') - assert user5.initials == 'us' - assert user6.initials == 'us' - assert user7.initials == 'us' + user5 = User(username="username_mock") + user6 = User(username="username_mock", firstname="f") + user7 = User(username="username_mock", lastname="l") + assert user5.initials == "us" + assert user6.initials == "us" + assert user7.initials == "us" # initials should be first two chars of firstname or lastname - user8 = User(username='username_mock', firstname='firstname_mock') - user9 = User(username='username_mock', lastname='lastname_mock') - assert user8.initials == 'fi' - assert user9.initials == 'la' + user8 = User(username="username_mock", firstname="firstname_mock") + user9 = User(username="username_mock", lastname="lastname_mock") + assert user8.initials == "fi" + assert user9.initials == "la" user1.__repr__() @@ -55,37 +63,51 @@ def test_string_representation_of_source(): def test_repr_representation_of_message(): source = factory.Source() - msg = Message(source=source, uuid="test", size=123, filename="1-test.docx", - download_url='http://test/test') + msg = Message( + source=source, + uuid="test", + size=123, + filename="1-test.docx", + download_url="http://test/test", + ) msg.__repr__() def test_repr_representation_of_file(): source = factory.Source() - file_ = File(source=source, uuid="test", size=123, filename="1-test.docx", - download_url='http://test/test') + file_ = File( + source=source, + uuid="test", + size=123, + filename="1-test.docx", + download_url="http://test/test", + ) file_.__repr__() def test_repr_representation_of_reply(): - user = User(username='hehe') + user = User(username="hehe") source = factory.Source() - reply = Reply(source=source, journalist=user, filename="1-reply.gpg", - size=1234, uuid='test') + reply = Reply(source=source, journalist=user, filename="1-reply.gpg", size=1234, uuid="test") reply.__repr__() def test_repr_representation_of_draft_reply(): - user = User(username='hehe') + user = User(username="hehe") source = factory.Source() - draft_reply = DraftReply(source=source, journalist=user, uuid='test') + draft_reply = DraftReply(source=source, journalist=user, uuid="test") draft_reply.__repr__() def test_string_representation_of_message(): source = factory.Source() - msg = Message(source=source, uuid="test", size=123, filename="1-test.docx", - download_url='http://test/test') + msg = Message( + source=source, + uuid="test", + size=123, + filename="1-test.docx", + download_url="http://test/test", + ) msg.__str__() msg.content = "hello" msg.__str__() @@ -93,34 +115,38 @@ def test_string_representation_of_message(): def test_string_representation_of_file(): source = factory.Source() - file_ = File(source=source, uuid="test", size=123, filename="1-test.docx", - download_url='http://test/test') + file_ = File( + source=source, + uuid="test", + size=123, + filename="1-test.docx", + download_url="http://test/test", + ) file_.__str__() file_.is_downloaded = True file_.__str__() def test_string_representation_of_reply(): - user = User(username='hehe') + user = User(username="hehe") source = factory.Source() - reply = Reply(source=source, journalist=user, filename="1-reply.gpg", - size=1234, uuid='test') + reply = Reply(source=source, journalist=user, filename="1-reply.gpg", size=1234, uuid="test") reply.__str__() reply.content = "hello" reply.__str__() def test_string_representation_of_draft_reply(): - user = User(username='hehe') + user = User(username="hehe") source = factory.Source() - draft_reply = DraftReply(source=source, journalist=user, uuid='test') + draft_reply = DraftReply(source=source, journalist=user, uuid="test") draft_reply.__str__() draft_reply.content = "hello" draft_reply.__str__() def test_string_representation_of_send_reply_status(): - reply_status = ReplySendStatus(name='teehee') + reply_status = ReplySendStatus(name="teehee") reply_status.__repr__() @@ -132,13 +158,22 @@ def test_string_representation_of_download_error(): def test_source_collection(): # Create some test submissions and replies source = factory.Source() - file_ = File(source=source, uuid="test", size=123, filename="2-test.doc.gpg", - download_url='http://test/test') - message = Message(source=source, uuid="test", size=123, filename="3-test.doc.gpg", - download_url='http://test/test') - user = User(username='hehe') - reply = Reply(source=source, journalist=user, filename="1-reply.gpg", - size=1234, uuid='test') + file_ = File( + source=source, + uuid="test", + size=123, + filename="2-test.doc.gpg", + download_url="http://test/test", + ) + message = Message( + source=source, + uuid="test", + size=123, + filename="3-test.doc.gpg", + download_url="http://test/test", + ) + user = User(username="hehe") + reply = Reply(source=source, journalist=user, filename="1-reply.gpg", size=1234, uuid="test") source.files = [file_] source.messages = [message] source.replies = [reply] @@ -152,16 +187,25 @@ def test_source_collection(): def test_source_server_collection(): # Create some test submissions and replies source = factory.Source() - file_ = File(source=source, uuid="test", size=123, filename="2-test.doc.gpg", - download_url='http://test/test') - message = Message(source=source, uuid="test", size=123, filename="3-test.doc.gpg", - download_url='http://test/test') - user = User(username='hehe') - reply = Reply(source=source, journalist=user, filename="1-reply.gpg", - size=1234, uuid='test') - draft_reply = DraftReply(source=source, journalist=user, - uuid='test', - timestamp=datetime.datetime(2002, 6, 6, 6, 0)) + file_ = File( + source=source, + uuid="test", + size=123, + filename="2-test.doc.gpg", + download_url="http://test/test", + ) + message = Message( + source=source, + uuid="test", + size=123, + filename="3-test.doc.gpg", + download_url="http://test/test", + ) + user = User(username="hehe") + reply = Reply(source=source, journalist=user, filename="1-reply.gpg", size=1234, uuid="test") + draft_reply = DraftReply( + source=source, journalist=user, uuid="test", timestamp=datetime.datetime(2002, 6, 6, 6, 0) + ) source.files = [file_] source.messages = [message] source.replies = [reply] @@ -179,21 +223,44 @@ def test_source_server_collection(): def test_source_collection_ordering_with_multiple_draft_replies(): # Create some test submissions, replies, and draft replies. source = factory.Source() - file_1 = File(source=source, uuid="test", size=123, filename="1-test.doc.gpg", - download_url='http://test/test') - message_2 = Message(source=source, uuid="test", size=123, filename="2-test.doc.gpg", - download_url='http://test/test') - user = User(username='hehe') - reply_3 = Reply(source=source, journalist=user, filename="3-reply.gpg", - size=1234, uuid='test') - draft_reply_4 = DraftReply(uuid='4', source=source, journalist=user, file_counter=3, - timestamp=datetime.datetime(2000, 6, 6, 6, 0)) - draft_reply_5 = DraftReply(uuid='5', source=source, journalist=user, file_counter=3, - timestamp=datetime.datetime(2001, 6, 6, 6, 0)) - reply_6 = Reply(source=source, journalist=user, filename="4-reply.gpg", - size=1234, uuid='test2') - draft_reply_7 = DraftReply(uuid='6', source=source, journalist=user, file_counter=4, - timestamp=datetime.datetime(2002, 6, 6, 6, 0)) + file_1 = File( + source=source, + uuid="test", + size=123, + filename="1-test.doc.gpg", + download_url="http://test/test", + ) + message_2 = Message( + source=source, + uuid="test", + size=123, + filename="2-test.doc.gpg", + download_url="http://test/test", + ) + user = User(username="hehe") + reply_3 = Reply(source=source, journalist=user, filename="3-reply.gpg", size=1234, uuid="test") + draft_reply_4 = DraftReply( + uuid="4", + source=source, + journalist=user, + file_counter=3, + timestamp=datetime.datetime(2000, 6, 6, 6, 0), + ) + draft_reply_5 = DraftReply( + uuid="5", + source=source, + journalist=user, + file_counter=3, + timestamp=datetime.datetime(2001, 6, 6, 6, 0), + ) + reply_6 = Reply(source=source, journalist=user, filename="4-reply.gpg", size=1234, uuid="test2") + draft_reply_7 = DraftReply( + uuid="6", + source=source, + journalist=user, + file_counter=4, + timestamp=datetime.datetime(2002, 6, 6, 6, 0), + ) source.files = [file_1] source.messages = [message_2] source.replies = [reply_3, reply_6] @@ -210,11 +277,11 @@ def test_source_collection_ordering_with_multiple_draft_replies(): def test_file_init(): - ''' + """ Check that: - We can't pass the file_counter attribute - The file_counter attribute is see correctly based off the filename - ''' + """ with pytest.raises(TypeError): File(file_counter=1) @@ -223,11 +290,11 @@ def test_file_init(): def test_message_init(): - ''' + """ Check that: - We can't pass the file_counter attribute - The file_counter attribute is see correctly based off the filename - ''' + """ with pytest.raises(TypeError): Message(file_counter=1) @@ -236,11 +303,11 @@ def test_message_init(): def test_reply_init(): - ''' + """ Check that: - We can't pass the file_counter attribute - The file_counter attribute is see correctly based off the filename - ''' + """ with pytest.raises(TypeError): Reply(file_counter=1) @@ -250,9 +317,9 @@ def test_reply_init(): def test_file_with_download_error(session, download_error_codes): f = factory.File() - download_error = session.query(DownloadError).filter_by( - name=DownloadErrorCodes.CHECKSUM_ERROR.name - ).one() + download_error = ( + session.query(DownloadError).filter_by(name=DownloadErrorCodes.CHECKSUM_ERROR.name).one() + ) f.download_error = download_error session.commit() @@ -262,9 +329,9 @@ def test_file_with_download_error(session, download_error_codes): def test_message_with_download_error(session, download_error_codes): m = factory.Message(is_decrypted=False, content=None) - download_error = session.query(DownloadError).filter_by( - name=DownloadErrorCodes.DECRYPTION_ERROR.name - ).one() + download_error = ( + session.query(DownloadError).filter_by(name=DownloadErrorCodes.DECRYPTION_ERROR.name).one() + ) m.download_error = download_error session.commit() @@ -274,9 +341,9 @@ def test_message_with_download_error(session, download_error_codes): def test_reply_with_download_error(session, download_error_codes): r = factory.Reply(is_decrypted=False, content=None) - download_error = session.query(DownloadError).filter_by( - name=DownloadErrorCodes.DECRYPTION_ERROR.name - ).one() + download_error = ( + session.query(DownloadError).filter_by(name=DownloadErrorCodes.DECRYPTION_ERROR.name).one() + ) r.download_error = download_error session.commit() diff --git a/tests/test_queue.py b/tests/test_queue.py index 46f155220..4ef9acff5 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,21 +1,21 @@ -''' +""" Testing for the ApiJobQueue and related classes. -''' -import pytest - +""" from queue import Queue + +import pytest from sdclientapi import RequestTimeoutError, ServerConnectionError -from securedrop_client.api_jobs.downloads import FileDownloadJob, MessageDownloadJob from securedrop_client.api_jobs.base import ApiInaccessibleError, PauseQueueJob -from securedrop_client.queue import RunnableQueue, ApiJobQueue +from securedrop_client.api_jobs.downloads import FileDownloadJob, MessageDownloadJob +from securedrop_client.queue import ApiJobQueue, RunnableQueue from tests import factory def test_RunnableQueue_init(mocker): mock_api_client = mocker.MagicMock() mock_session_maker = mocker.MagicMock() - mocker.patch('securedrop_client.queue.RunnableQueue.resume', return_value=mocker.MagicMock()) + mocker.patch("securedrop_client.queue.RunnableQueue.resume", return_value=mocker.MagicMock()) queue = RunnableQueue(mock_api_client, mock_session_maker) @@ -27,13 +27,13 @@ def test_RunnableQueue_init(mocker): def test_RunnableQueue_happy_path(mocker): - ''' + """ Add one job to the queue, run it. - ''' + """ mock_api_client = mocker.MagicMock() mock_session = mocker.MagicMock() mock_session_maker = mocker.MagicMock(return_value=mock_session) - return_value = 'foo' + return_value = "foo" dummy_job_cls = factory.dummy_job_factory(mocker, return_value) queue = RunnableQueue(mock_api_client, mock_session_maker) @@ -48,10 +48,10 @@ def test_RunnableQueue_happy_path(mocker): @pytest.mark.parametrize("exception", [RequestTimeoutError, ServerConnectionError]) def test_RunnableQueue_job_timeout(mocker, exception): - ''' + """ Add two jobs to the queue. The first times out, and then gets resubmitted for the next pass through the loop. - ''' + """ queue = RunnableQueue(mocker.MagicMock(), mocker.MagicMock()) queue.pause = mocker.MagicMock() job_cls = factory.dummy_job_factory(mocker, exception(), remaining_attempts=5) @@ -63,6 +63,7 @@ def test_RunnableQueue_job_timeout(mocker, exception): # use our fake pause method instead def fake_pause() -> None: queue.add_job(PauseQueueJob()) + queue.pause.emit = fake_pause # Add two jobs that timeout during processing to the queue @@ -74,7 +75,7 @@ def fake_pause() -> None: assert queue.queue.qsize() == 2 # queue contains: job1, job2 # now process after making it so job1 no longer times out - job1.return_value = 'mock' + job1.return_value = "mock" queue.process() assert queue.queue.qsize() == 1 # queue contains: job2 assert queue.queue.get(block=True) == (1, job2) @@ -97,7 +98,7 @@ def test_RunnableQueue_high_priority_jobs_run_first_and_in_fifo_order(mocker): mock_session = mocker.MagicMock() mock_session_maker = mocker.MagicMock(return_value=mock_session) - return_value = 'wat' + return_value = "wat" job_cls_high_priority = factory.dummy_job_factory(mocker, return_value) job_cls_low_priority = factory.dummy_job_factory(mocker, return_value) @@ -122,15 +123,15 @@ def test_RunnableQueue_high_priority_jobs_run_first_and_in_fifo_order(mocker): def test_RunnableQueue_resubmitted_jobs(mocker): - ''' + """ Verify that jobs that fail due to timeout are resubmitted without modifying order_number. - ''' + """ mock_api_client = mocker.MagicMock() mock_session = mocker.MagicMock() mock_session_maker = mocker.MagicMock(return_value=mock_session) - job_cls_high_priority = factory.dummy_job_factory(mocker, 'mock') - job_cls_low_priority = factory.dummy_job_factory(mocker, 'mock') + job_cls_high_priority = factory.dummy_job_factory(mocker, "mock") + job_cls_low_priority = factory.dummy_job_factory(mocker, "mock") queue = RunnableQueue(mock_api_client, mock_session_maker) queue.JOB_PRIORITIES = {job_cls_high_priority: 1, job_cls_low_priority: 2} @@ -157,17 +158,17 @@ def test_RunnableQueue_resubmitted_jobs(mocker): def test_RunnableQueue_duplicate_jobs(mocker): - ''' + """ Verify that duplicate jobs are not added to the queue. - ''' + """ mock_api_client = mocker.MagicMock() mock_session = mocker.MagicMock() mock_session_maker = mocker.MagicMock(return_value=mock_session) - dl_job = FileDownloadJob('mock', 'mock', 'mock') - msg_dl_job = MessageDownloadJob('mock', 'mock', 'mock') + dl_job = FileDownloadJob("mock", "mock", "mock") + msg_dl_job = MessageDownloadJob("mock", "mock", "mock") queue = RunnableQueue(mock_api_client, mock_session_maker) - debug_logger = mocker.patch('securedrop_client.queue.logger.debug') + debug_logger = mocker.patch("securedrop_client.queue.logger.debug") # Queue begins empty (0 entries). assert len(queue.queue.queue) == 0 @@ -179,8 +180,7 @@ def test_RunnableQueue_duplicate_jobs(mocker): queue.add_job(dl_job) assert len(queue.queue.queue) == 1 - log_msg = 'Duplicate job {}, skipping'.format( - dl_job) + log_msg = "Duplicate job {}, skipping".format(dl_job) debug_logger.call_args[1] == log_msg # Now add a _different_ job with the same arguments (same uuid). @@ -195,13 +195,13 @@ def test_RunnableQueue_duplicate_jobs(mocker): def test_RunnableQueue_job_generic_exception(mocker): - ''' + """ Add two jobs to the queue, the first of which will cause a generic exception, which is handled in _do_call_api. Ensure that the queue continues processing jobs after dropping a job that runs into a generic exception. - ''' + """ job1_cls = factory.dummy_job_factory(mocker, Exception()) # processing skips job - job2_cls = factory.dummy_job_factory(mocker, 'mock') + job2_cls = factory.dummy_job_factory(mocker, "mock") job1 = job1_cls() job2 = job2_cls() queue = RunnableQueue(mocker.MagicMock(), mocker.MagicMock()) @@ -218,10 +218,10 @@ def test_RunnableQueue_job_generic_exception(mocker): def test_RunnableQueue_does_not_run_jobs_when_not_authed(mocker): - ''' + """ Check that a job that sees an ApiInaccessibleError does not get resubmitted since it is not authorized and that its api_client is None. - ''' + """ queue = RunnableQueue(mocker.MagicMock(), mocker.MagicMock()) job_cls = factory.dummy_job_factory(mocker, ApiInaccessibleError()) queue.JOB_PRIORITIES = {PauseQueueJob: 0, job_cls: 1} @@ -242,20 +242,20 @@ def test_ApiJobQueue_enqueue_when_queues_are_running(mocker): job_queue = ApiJobQueue(mock_client, mock_session_maker) job_priority = 2 - dummy_job = factory.dummy_job_factory(mocker, 'mock')() + dummy_job = factory.dummy_job_factory(mocker, "mock")() job_queue.JOB_PRIORITIES = {FileDownloadJob: job_priority, type(dummy_job): job_priority} - mock_download_file_queue = mocker.patch.object(job_queue, 'download_file_queue') - mock_main_queue = mocker.patch.object(job_queue, 'main_queue') - mock_download_file_add_job = mocker.patch.object(mock_download_file_queue, 'add_job') - mock_main_queue_add_job = mocker.patch.object(mock_main_queue, 'add_job') - job_queue.main_queue.api_client = 'has a value' - job_queue.download_file_queue.api_client = 'has a value' + mock_download_file_queue = mocker.patch.object(job_queue, "download_file_queue") + mock_main_queue = mocker.patch.object(job_queue, "main_queue") + mock_download_file_add_job = mocker.patch.object(mock_download_file_queue, "add_job") + mock_main_queue_add_job = mocker.patch.object(mock_main_queue, "add_job") + job_queue.main_queue.api_client = "has a value" + job_queue.download_file_queue.api_client = "has a value" job_queue.main_thread.isRunning = mocker.MagicMock(return_value=True) job_queue.download_file_thread.isRunning = mocker.MagicMock(return_value=True) - dl_job = FileDownloadJob('mock', 'mock', 'mock') + dl_job = FileDownloadJob("mock", "mock", "mock") job_queue.enqueue(dl_job) mock_download_file_add_job.assert_called_once_with(dl_job) @@ -267,7 +267,7 @@ def test_ApiJobQueue_enqueue_when_queues_are_running(mocker): mock_main_queue.reset_mock() mock_main_queue_add_job.reset_mock() - job_queue.enqueue(FileDownloadJob('mock', 'mock', 'mock')) + job_queue.enqueue(FileDownloadJob("mock", "mock", "mock")) assert not mock_main_queue_add_job.called @@ -289,19 +289,19 @@ def test_ApiJobQueue_enqueue_when_queues_are_not_running(mocker): job_queue = ApiJobQueue(mock_client, mock_session_maker) job_priority = 2 - dummy_job = factory.dummy_job_factory(mocker, 'mock')() + dummy_job = factory.dummy_job_factory(mocker, "mock")() job_queue.JOB_PRIORITIES = {FileDownloadJob: job_priority, type(dummy_job): job_priority} - mock_download_file_queue = mocker.patch.object(job_queue, 'download_file_queue') - mock_main_queue = mocker.patch.object(job_queue, 'main_queue') - mock_download_file_add_job = mocker.patch.object(mock_download_file_queue, 'add_job') - mock_main_queue_add_job = mocker.patch.object(mock_main_queue, 'add_job') - job_queue.main_queue.api_client = 'has a value' - job_queue.download_file_queue.api_client = 'has a value' + mock_download_file_queue = mocker.patch.object(job_queue, "download_file_queue") + mock_main_queue = mocker.patch.object(job_queue, "main_queue") + mock_download_file_add_job = mocker.patch.object(mock_download_file_queue, "add_job") + mock_main_queue_add_job = mocker.patch.object(mock_main_queue, "add_job") + job_queue.main_queue.api_client = "has a value" + job_queue.download_file_queue.api_client = "has a value" job_queue.stop() # queues are already not running, but just in case the code changes one day - dl_job = FileDownloadJob('mock', 'mock', 'mock') + dl_job = FileDownloadJob("mock", "mock", "mock") job_queue.enqueue(dl_job) mock_download_file_add_job.assert_not_called() @@ -310,9 +310,9 @@ def test_ApiJobQueue_enqueue_when_queues_are_not_running(mocker): def test_ApiJobQueue_on_main_queue_paused(mocker): job_queue = ApiJobQueue(mocker.MagicMock(), mocker.MagicMock()) - mocker.patch.object(job_queue, 'paused') + mocker.patch.object(job_queue, "paused") pause_job = PauseQueueJob() - mocker.patch('securedrop_client.queue.PauseQueueJob', return_value=pause_job) + mocker.patch("securedrop_client.queue.PauseQueueJob", return_value=pause_job) job_queue.on_main_queue_paused() @@ -321,9 +321,9 @@ def test_ApiJobQueue_on_main_queue_paused(mocker): def test_ApiJobQueue_on_file_download_queue_paused(mocker): job_queue = ApiJobQueue(mocker.MagicMock(), mocker.MagicMock()) - mocker.patch.object(job_queue, 'paused') + mocker.patch.object(job_queue, "paused") pause_job = PauseQueueJob() - mocker.patch('securedrop_client.queue.PauseQueueJob', return_value=pause_job) + mocker.patch("securedrop_client.queue.PauseQueueJob", return_value=pause_job) job_queue.on_file_download_queue_paused() @@ -335,8 +335,8 @@ def test_ApiJobQueue_resume_queues_emits_resume_signal_if_queues_are_running(moc Ensure resume signal is emitted if the queues are running. """ job_queue = ApiJobQueue(mocker.MagicMock(), mocker.MagicMock()) - mocker.patch.object(job_queue.main_queue, 'resume') - mocker.patch.object(job_queue.download_file_queue, 'resume') + mocker.patch.object(job_queue.main_queue, "resume") + mocker.patch.object(job_queue.download_file_queue, "resume") job_queue.main_thread.isRunning = mocker.MagicMock(return_value=True) job_queue.download_file_thread.isRunning = mocker.MagicMock(return_value=True) @@ -351,8 +351,8 @@ def test_ApiJobQueue_resume_queues_does_not_emit_resume_signal_if_queues_are_not Ensure resume signal is not emitted if the queues ar not running. """ job_queue = ApiJobQueue(mocker.MagicMock(), mocker.MagicMock()) - mocker.patch.object(job_queue.main_queue, 'resume') - mocker.patch.object(job_queue.download_file_queue, 'resume') + mocker.patch.object(job_queue.main_queue, "resume") + mocker.patch.object(job_queue.download_file_queue, "resume") job_queue.main_thread.isRunning = mocker.MagicMock(return_value=False) job_queue.download_file_thread.isRunning = mocker.MagicMock(return_value=False) @@ -367,14 +367,14 @@ def test_ApiJobQueue_enqueue_no_auth(mocker): mock_session_maker = mocker.MagicMock() job_queue = ApiJobQueue(mock_client, mock_session_maker) - mock_download_file_queue = mocker.patch.object(job_queue, 'download_file_queue') - mock_main_queue = mocker.patch.object(job_queue, 'main_queue') - mock_download_file_add_job = mocker.patch.object(mock_download_file_queue, 'add_job') - mock_main_queue_add_job = mocker.patch.object(mock_main_queue, 'add_job') + mock_download_file_queue = mocker.patch.object(job_queue, "download_file_queue") + mock_main_queue = mocker.patch.object(job_queue, "main_queue") + mock_download_file_add_job = mocker.patch.object(mock_download_file_queue, "add_job") + mock_main_queue_add_job = mocker.patch.object(mock_main_queue, "add_job") job_queue.main_queue.api_client = None job_queue.download_file_queue.api_client = None - dummy_job = factory.dummy_job_factory(mocker, 'mock')() + dummy_job = factory.dummy_job_factory(mocker, "mock")() job_queue.JOB_PRIORITIES = {type(dummy_job): 1} job_queue.enqueue(dummy_job) @@ -383,19 +383,19 @@ def test_ApiJobQueue_enqueue_no_auth(mocker): def test_ApiJobQueue_start_if_queues_not_running(mocker): - ''' + """ Ensure token is passed to the queues and that they are started. - ''' + """ mock_api = mocker.MagicMock() mock_client = mocker.MagicMock() mock_session_maker = mocker.MagicMock() job_queue = ApiJobQueue(mock_client, mock_session_maker) - mock_main_queue = mocker.patch.object(job_queue, 'main_queue') - mock_download_file_queue = mocker.patch.object(job_queue, 'download_file_queue') - mock_main_thread = mocker.patch.object(job_queue, 'main_thread') - mock_download_file_thread = mocker.patch.object(job_queue, 'download_file_thread') + mock_main_queue = mocker.patch.object(job_queue, "main_queue") + mock_download_file_queue = mocker.patch.object(job_queue, "download_file_queue") + mock_main_thread = mocker.patch.object(job_queue, "main_thread") + mock_download_file_thread = mocker.patch.object(job_queue, "download_file_thread") job_queue.main_thread.isRunning = mocker.MagicMock(return_value=False) job_queue.download_file_thread.isRunning = mocker.MagicMock(return_value=False) @@ -409,19 +409,19 @@ def test_ApiJobQueue_start_if_queues_not_running(mocker): def test_ApiJobQueue_start_if_queues_running(mocker): - ''' + """ Ensure token is passed to the queues that are already started. - ''' + """ mock_api = mocker.MagicMock() mock_client = mocker.MagicMock() mock_session_maker = mocker.MagicMock() job_queue = ApiJobQueue(mock_client, mock_session_maker) - mock_main_queue = mocker.patch.object(job_queue, 'main_queue') - mock_download_file_queue = mocker.patch.object(job_queue, 'download_file_queue') - mock_main_thread = mocker.patch.object(job_queue, 'main_thread') - mock_download_file_thread = mocker.patch.object(job_queue, 'download_file_thread') + mock_main_queue = mocker.patch.object(job_queue, "main_queue") + mock_download_file_queue = mocker.patch.object(job_queue, "download_file_queue") + mock_main_thread = mocker.patch.object(job_queue, "main_thread") + mock_download_file_thread = mocker.patch.object(job_queue, "download_file_thread") job_queue.main_thread.isRunning = mocker.MagicMock(return_value=True) job_queue.download_file_thread.isRunning = mocker.MagicMock(return_value=True) diff --git a/tests/test_resources.py b/tests/test_resources.py index 44ba9b782..4ad1959fa 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,11 +1,12 @@ """ Tests for the resources sub-module. """ -import securedrop_client.resources -from PyQt5.QtGui import QIcon, QPixmap, QMovie +from PyQt5.QtGui import QIcon, QMovie, QPixmap from PyQt5.QtSvg import QSvgWidget from PyQt5.QtWidgets import QApplication +import securedrop_client.resources + app = QApplication([]) @@ -14,11 +15,9 @@ def test_path(mocker): Ensure the resource_filename function is called with the expected args and the path function under test returns its result. """ - r = mocker.patch('securedrop_client.resources.resource_filename', - return_value='bar') - assert securedrop_client.resources.path('foo') == 'bar' - r.assert_called_once_with(securedrop_client.resources.__name__, - 'images/foo') + r = mocker.patch("securedrop_client.resources.resource_filename", return_value="bar") + assert securedrop_client.resources.path("foo") == "bar" + r.assert_called_once_with(securedrop_client.resources.__name__, "images/foo") def test_load_icon(): @@ -26,14 +25,15 @@ def test_load_icon(): Check the load_icon function returns the expected QIcon object. """ result = securedrop_client.resources.load_icon( - 'normal_mock', - 'disabled_mock', - 'active_mock', - 'selected_mock', - 'normal_off_mock', - 'disabled_off_mock', - 'active_off_mock', - 'selected_off_mock') + "normal_mock", + "disabled_mock", + "active_mock", + "selected_mock", + "normal_off_mock", + "disabled_off_mock", + "active_off_mock", + "selected_off_mock", + ) assert isinstance(result, QIcon) @@ -41,7 +41,7 @@ def test_load_svg(): """ Check the load_svg function returns the expected QSvgWidget object. """ - result = securedrop_client.resources.load_svg('paperclip.svg') + result = securedrop_client.resources.load_svg("paperclip.svg") assert isinstance(result, QSvgWidget) @@ -49,7 +49,7 @@ def test_load_image(): """ Check the load_image function returns the expected QPixmap object. """ - result = securedrop_client.resources.load_image('icon') + result = securedrop_client.resources.load_image("icon") assert isinstance(result, QPixmap) @@ -58,16 +58,14 @@ def test_load_css(mocker): Ensure the resource_string function is called with the expected args and the load_css function returns its result. """ - rs = mocker.patch('securedrop_client.resources.resource_string', - return_value=b'foo') - assert 'foo' == securedrop_client.resources.load_css('foo') - rs.assert_called_once_with(securedrop_client.resources.__name__, - 'css/foo') + rs = mocker.patch("securedrop_client.resources.resource_string", return_value=b"foo") + assert "foo" == securedrop_client.resources.load_css("foo") + rs.assert_called_once_with(securedrop_client.resources.__name__, "css/foo") def test_load_movie(): """ Check the load_movie function returns the expected QMovie object. """ - result = securedrop_client.resources.load_movie('download_animation.gif') + result = securedrop_client.resources.load_movie("download_animation.gif") assert isinstance(result, QMovie) diff --git a/tests/test_storage.py b/tests/test_storage.py index 40c1ab4ae..c2ad9d2c3 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -2,28 +2,49 @@ Tests for storage sync logic. """ import datetime -import pytest import os import time import uuid -from dateutil.parser import parse - -from sdclientapi import Submission, Reply -from sqlalchemy.orm.exc import NoResultFound +import pytest +from dateutil.parser import parse from PyQt5.QtCore import QThread +from sdclientapi import Reply, Submission +from sqlalchemy.orm.exc import NoResultFound import securedrop_client.db -from securedrop_client.storage import get_local_sources, get_local_messages, get_local_replies, \ - get_remote_data, update_local_storage, update_sources, update_files, update_messages, \ - update_replies, find_or_create_user, find_new_messages, find_new_replies, \ - delete_single_submission_or_reply_on_disk, get_local_files, find_new_files, \ - source_exists, set_message_or_reply_content, mark_as_downloaded, mark_as_decrypted, get_file, \ - get_message, get_reply, update_and_get_user, update_missing_files, mark_as_not_downloaded, \ - mark_all_pending_drafts_as_failed, delete_local_source_by_uuid, update_file_size, \ - update_draft_replies - from securedrop_client import db +from securedrop_client.storage import ( + delete_local_source_by_uuid, + delete_single_submission_or_reply_on_disk, + find_new_files, + find_new_messages, + find_new_replies, + find_or_create_user, + get_file, + get_local_files, + get_local_messages, + get_local_replies, + get_local_sources, + get_message, + get_remote_data, + get_reply, + mark_all_pending_drafts_as_failed, + mark_as_decrypted, + mark_as_downloaded, + mark_as_not_downloaded, + set_message_or_reply_content, + source_exists, + update_and_get_user, + update_draft_replies, + update_file_size, + update_files, + update_local_storage, + update_messages, + update_missing_files, + update_replies, + update_sources, +) from tests import factory @@ -33,10 +54,16 @@ def make_remote_message(source_uuid, file_counter=1): 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 Submission(download_url='test', filename='{}-submission.msg.gpg'.format(file_counter), - is_read=False, size=123, source_url=source_url, - submission_url='test', uuid=str(uuid.uuid4())) + source_url = "/api/v1/sources/{}".format(source_uuid) + return Submission( + download_url="test", + filename="{}-submission.msg.gpg".format(file_counter), + is_read=False, + size=123, + source_url=source_url, + submission_url="test", + uuid=str(uuid.uuid4()), + ) def make_remote_submission(source_uuid): @@ -45,23 +72,36 @@ def make_remote_submission(source_uuid): 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 Submission(download_url='test', filename='1-submission.filename', - is_read=False, size=123, source_url=source_url, - submission_url='test', uuid=str(uuid.uuid4())) + source_url = "/api/v1/sources/{}".format(source_uuid) + return Submission( + download_url="test", + filename="1-submission.filename", + is_read=False, + size=123, + source_url=source_url, + submission_url="test", + uuid=str(uuid.uuid4()), + ) -def make_remote_reply(source_uuid, journalist_uuid='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='1-reply.filename', journalist_uuid=journalist_uuid, - journalist_username='test', file_counter=1, - is_deleted_by_source=False, reply_url='test', size=1234, - source_url=source_url, uuid=str(uuid.uuid4())) + source_url = "/api/v1/sources/{}".format(source_uuid) + return Reply( + filename="1-reply.filename", + journalist_uuid=journalist_uuid, + journalist_username="test", + file_counter=1, + is_deleted_by_source=False, + reply_url="test", + size=1234, + source_url=source_url, + uuid=str(uuid.uuid4()), + ) def test_get_local_sources(mocker): @@ -80,16 +120,16 @@ def test_delete_local_source_by_uuid(homedir, mocker): """ mock_session = mocker.MagicMock() source = factory.RemoteSource() - source.journalist_filename = 'sourcey_mcsource' + source.journalist_filename = "sourcey_mcsource" # Make source folder source_directory = os.path.join(homedir, source.journalist_filename) os.mkdir(source_directory) # Make document in source disk to be deleted - path_to_source_document = os.path.join(source_directory, 'teehee') - with open(path_to_source_document, 'w') as f: - f.write('this is a source document') + path_to_source_document = os.path.join(source_directory, "teehee") + with open(path_to_source_document, "w") as f: + f.write("this is a source document") mock_session.query().filter_by().one_or_none.return_value = source mock_session.query.reset_mock() @@ -102,10 +142,10 @@ def test_delete_local_source_by_uuid(homedir, mocker): # Ensure both source folder and its containing file are gone. with pytest.raises(FileNotFoundError): - f = open(path_to_source_document, 'r') + f = open(path_to_source_document, "r") with pytest.raises(FileNotFoundError): - f = open(source_directory, 'r') + f = open(source_directory, "r") def test_delete_local_source_by_uuid_no_files(homedir, mocker): @@ -115,7 +155,7 @@ def test_delete_local_source_by_uuid_no_files(homedir, mocker): """ mock_session = mocker.MagicMock() source = factory.RemoteSource() - source.journalist_filename = 'sourcey_mcsource' + source.journalist_filename = "sourcey_mcsource" mock_session.query().filter_by().one_or_none.return_value = source mock_session.query.reset_mock() delete_local_source_by_uuid(mock_session, "uuid", homedir) @@ -132,8 +172,7 @@ def test_get_local_messages(mocker): """ mock_session = mocker.MagicMock() get_local_messages(mock_session) - mock_session.query.\ - assert_called_once_with(securedrop_client.db.Message) + mock_session.query.assert_called_once_with(securedrop_client.db.Message) def test_get_local_files(mocker): @@ -142,8 +181,7 @@ def test_get_local_files(mocker): """ mock_session = mocker.MagicMock() get_local_files(mock_session) - mock_session.query.\ - assert_called_once_with(securedrop_client.db.File) + mock_session.query.assert_called_once_with(securedrop_client.db.File) def test_get_local_replies(mocker): @@ -161,7 +199,7 @@ def test_get_remote_data_handles_api_error(mocker): caller handles the exception. """ mock_api = mocker.MagicMock() - mock_api.get_sources.side_effect = Exception('BANG!') + mock_api.get_sources.side_effect = Exception("BANG!") with pytest.raises(Exception): get_remote_data(mock_api) @@ -173,15 +211,27 @@ def test_get_remote_data(mocker): # Some source, submission and reply objects from the API. mock_api = mocker.MagicMock() source = factory.RemoteSource() - mock_api.get_sources.return_value = [source, ] + mock_api.get_sources.return_value = [ + source, + ] submission = mocker.MagicMock() - mock_api.get_all_submissions.return_value = [submission, ] + mock_api.get_all_submissions.return_value = [ + submission, + ] reply = mocker.MagicMock() - mock_api.get_all_replies.return_value = [reply, ] + mock_api.get_all_replies.return_value = [ + reply, + ] sources, submissions, replies = get_remote_data(mock_api) - assert sources == [source, ] - assert submissions == [submission, ] - assert replies == [reply, ] + assert sources == [ + source, + ] + assert submissions == [ + submission, + ] + assert replies == [ + reply, + ] def test_update_local_storage(homedir, mocker, session_maker): @@ -190,8 +240,8 @@ def test_update_local_storage(homedir, mocker, session_maker): the state of the local database are called with the necessary data. """ remote_source = factory.RemoteSource() - remote_message = mocker.Mock(filename='1-foo.msg.gpg') - remote_file = mocker.Mock(filename='2-foo.gpg') + remote_message = mocker.Mock(filename="1-foo.msg.gpg") + remote_file = mocker.Mock(filename="2-foo.gpg") remote_submissions = [remote_message, remote_file] remote_reply = mocker.MagicMock() # Some local source, submission and reply objects from the local database. @@ -201,18 +251,15 @@ def test_update_local_storage(homedir, mocker, session_maker): local_message = mocker.MagicMock() local_reply = mocker.MagicMock() mock_session.query().all = mocker.Mock() - mock_session.query().all.side_effect = [ - [local_file], [local_message], [local_reply]] + mock_session.query().all.side_effect = [[local_file], [local_message], [local_reply]] mock_session.query().order_by().all = mocker.Mock() mock_session.query().order_by().all.side_effect = [[local_source]] - src_fn = mocker.patch('securedrop_client.storage.update_sources') - rpl_fn = mocker.patch('securedrop_client.storage.update_replies') - file_fn = mocker.patch('securedrop_client.storage.update_files') - msg_fn = mocker.patch('securedrop_client.storage.update_messages') + src_fn = mocker.patch("securedrop_client.storage.update_sources") + rpl_fn = mocker.patch("securedrop_client.storage.update_replies") + file_fn = mocker.patch("securedrop_client.storage.update_files") + msg_fn = mocker.patch("securedrop_client.storage.update_messages") - update_local_storage( - mock_session, [remote_source], remote_submissions, [remote_reply], homedir - ) + update_local_storage(mock_session, [remote_source], remote_submissions, [remote_reply], homedir) src_fn.assert_called_once_with([remote_source], [local_source], mock_session, homedir) rpl_fn.assert_called_once_with([remote_reply], [local_reply], mock_session, homedir) file_fn.assert_called_once_with([remote_file], [local_file], mock_session, homedir) @@ -276,7 +323,7 @@ def delayed_update_messages(remote_submissions, local_submissions, session, data assert source_exists(session, source.uuid) is False update_messages(remote_submissions, local_submissions, session, data_dir) - mocker.patch('securedrop_client.storage.update_messages', delayed_update_messages) + mocker.patch("securedrop_client.storage.update_messages", delayed_update_messages) # simulate update_local_storage being called as part of the sync operation update_local_storage(session, sources, [message1, message2], [], homedir) @@ -311,7 +358,9 @@ def test_update_sources(homedir, mocker, session_maker, session): # This local source already exists in the API results and will be updated. local_source1 = factory.Source( journalist_designation=source_update.journalist_designation, - uuid=source_update.uuid, public_key=None, fingerprint=None + uuid=source_update.uuid, + public_key=None, + fingerprint=None, ) # This local source does not exist in the API results and will be @@ -324,8 +373,7 @@ def test_update_sources(homedir, mocker, session_maker, session): local_sources = [local_source1, local_source2] - file_delete_fcn = mocker.patch( - 'securedrop_client.storage.delete_source_collection') + file_delete_fcn = mocker.patch("securedrop_client.storage.delete_source_collection") update_sources(remote_sources, local_sources, session, homedir) @@ -334,8 +382,8 @@ def test_update_sources(homedir, mocker, session_maker, session): updated_source = session.query(db.Source).filter_by(uuid=source_update.uuid).one() assert updated_source.journalist_designation == source_update.journalist_designation assert updated_source.is_flagged == source_update.is_flagged - assert updated_source.public_key == source_update.key['public'] - assert updated_source.fingerprint == source_update.key['fingerprint'] + assert updated_source.public_key == source_update.key["public"] + assert updated_source.fingerprint == source_update.key["fingerprint"] assert updated_source.interaction_count == source_update.interaction_count assert updated_source.is_starred == source_update.is_starred assert updated_source.last_updated == parse(source_update.last_updated) @@ -346,8 +394,8 @@ def test_update_sources(homedir, mocker, session_maker, session): assert new_source.uuid == source_create.uuid 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['public'] - assert new_source.fingerprint == source_create.key['fingerprint'] + assert new_source.public_key == source_create.key["public"] + assert new_source.fingerprint == source_create.key["fingerprint"] 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) @@ -368,15 +416,13 @@ def add_test_file_to_temp_dir(home_dir, filename): dest = os.path.join(home_dir, filename) os.makedirs(os.path.dirname(dest), mode=0o700, exist_ok=True) - with open(dest, 'w') as f: - f.write('I am test content for tests') + with open(dest, "w") as f: + f.write("I am test content for tests") return dest -def test_update_submissions_deletes_files_associated_with_the_submission( - homedir, - mocker): +def test_update_submissions_deletes_files_associated_with_the_submission(homedir, mocker): """ Check that: @@ -389,25 +435,27 @@ def test_update_submissions_deletes_files_associated_with_the_submission( # A local submission object. To ensure that all files from various # stages of processing are cleaned up, we'll add several filenames. - server_filename = '1-pericardial-surfacing-msg.gpg' - local_filename_when_decrypted = '1-pericardial-surfacing-msg' + server_filename = "1-pericardial-surfacing-msg.gpg" + local_filename_when_decrypted = "1-pericardial-surfacing-msg" local_submission = mocker.MagicMock() - local_submission.uuid = 'test-uuid' + local_submission.uuid = "test-uuid" local_submission.filename = server_filename local_submission_source_journalist_filename = "pericardial_surfacing" source_directory = os.path.join(homedir, local_submission_source_journalist_filename) local_submission.location = mocker.MagicMock( - return_value=os.path.join(source_directory, local_filename_when_decrypted)) - abs_local_filename = add_test_file_to_temp_dir( - source_directory, local_filename_when_decrypted) + return_value=os.path.join(source_directory, local_filename_when_decrypted) + ) + abs_local_filename = add_test_file_to_temp_dir(source_directory, local_filename_when_decrypted) local_submissions = [local_submission] # There needs to be a corresponding local_source. local_source = mocker.MagicMock() - local_source.uuid = 'test-source-uuid' + local_source.uuid = "test-source-uuid" local_source.id = 666 - mock_session.query().filter_by.return_value = [local_source, ] + mock_session.query().filter_by.return_value = [ + local_source, + ] update_files(remote_submissions, local_submissions, mock_session, homedir) # Ensure the files associated with the submission are deleted on disk. @@ -420,9 +468,7 @@ def test_update_submissions_deletes_files_associated_with_the_submission( assert mock_session.commit.call_count == 1 -def test_update_replies_deletes_files_associated_with_the_reply( - homedir, - mocker): +def test_update_replies_deletes_files_associated_with_the_reply(homedir, mocker): """ Check that: @@ -435,25 +481,27 @@ def test_update_replies_deletes_files_associated_with_the_reply( # A local reply object. To ensure that all files from various # stages of processing are cleaned up, we'll add several filenames. - server_filename = '1-pericardial-surfacing-reply.gpg' - local_filename_when_decrypted = '1-pericardial-surfacing-reply' + server_filename = "1-pericardial-surfacing-reply.gpg" + local_filename_when_decrypted = "1-pericardial-surfacing-reply" local_reply = mocker.MagicMock() - local_reply.uuid = 'test-uuid' + local_reply.uuid = "test-uuid" local_reply.filename = server_filename local_reply_source_journalist_filename = "pericardial_surfacing" source_directory = os.path.join(homedir, local_reply_source_journalist_filename) local_reply.location = mocker.MagicMock( - return_value=os.path.join(source_directory, local_filename_when_decrypted)) - abs_local_filename = add_test_file_to_temp_dir( - source_directory, local_filename_when_decrypted) + return_value=os.path.join(source_directory, local_filename_when_decrypted) + ) + abs_local_filename = add_test_file_to_temp_dir(source_directory, local_filename_when_decrypted) local_replies = [local_reply] # There needs to be a corresponding local_source. local_source = mocker.MagicMock() - local_source.uuid = 'test-source-uuid' + local_source.uuid = "test-source-uuid" local_source.id = 666 - mock_session.query().filter_by.return_value = [local_source, ] + mock_session.query().filter_by.return_value = [ + local_source, + ] update_replies(remote_replies, local_replies, mock_session, homedir) # Ensure the file associated with the reply are deleted on disk. @@ -466,11 +514,7 @@ def test_update_replies_deletes_files_associated_with_the_reply( assert mock_session.commit.call_count == 1 -def test_update_sources_deletes_files_associated_with_the_source( - homedir, - mocker, - session_maker -): +def test_update_sources_deletes_files_associated_with_the_source(homedir, mocker, session_maker): """ Check that: @@ -485,40 +529,52 @@ def test_update_sources_deletes_files_associated_with_the_source( # various stages of processing are cleaned up, we'll add several filenames # associated with each message, document, and reply for each stage of processing. # This simulates if a step failed. - msg_server_filename = '1-pericardial-surfacing-msg.gpg' - msg_local_filename_decrypted = '1-pericardial-surfacing-msg' + msg_server_filename = "1-pericardial-surfacing-msg.gpg" + msg_local_filename_decrypted = "1-pericardial-surfacing-msg" - file_server_filename = '1-pericardial-surfacing-doc.gz.gpg' - file_local_filename_decompressed = '1-pericardial-surfacing-doc' - file_local_filename_decrypted = '1-pericardial-surfacing-doc.gz' + file_server_filename = "1-pericardial-surfacing-doc.gz.gpg" + file_local_filename_decompressed = "1-pericardial-surfacing-doc" + file_local_filename_decrypted = "1-pericardial-surfacing-doc.gz" - reply_server_filename = '1-pericardial-surfacing-reply.gpg' - reply_local_filename_decrypted = '1-pericardial-surfacing-reply' + reply_server_filename = "1-pericardial-surfacing-reply.gpg" + reply_local_filename_decrypted = "1-pericardial-surfacing-reply" # Here we're not mocking out the models use so that we can use the collection attribute. local_source = factory.Source(journalist_designation="beep_boop") file_submission = db.File( - source=local_source, uuid="test", size=123, filename=file_server_filename, - download_url='http://test/test') + source=local_source, + uuid="test", + size=123, + filename=file_server_filename, + download_url="http://test/test", + ) msg_submission = db.File( - source=local_source, uuid="test", size=123, filename=msg_server_filename, - download_url='http://test/test') - user = db.User(username='hehe') + source=local_source, + uuid="test", + size=123, + filename=msg_server_filename, + download_url="http://test/test", + ) + user = db.User(username="hehe") reply = db.Reply( - source=local_source, journalist=user, filename=reply_server_filename, - size=1234, uuid='test') + source=local_source, journalist=user, filename=reply_server_filename, size=1234, uuid="test" + ) local_source.submissions = [file_submission, msg_submission] local_source.replies = [reply] # Make the test files on disk in tmpdir so we can check they get deleted. test_filename_absolute_paths = [] sourcedir = os.path.join(homedir, local_source.journalist_filename) - for test_filename in [msg_server_filename, msg_local_filename_decrypted, - file_server_filename, file_local_filename_decompressed, - file_local_filename_decrypted, reply_server_filename, - reply_local_filename_decrypted]: - abs_server_filename = add_test_file_to_temp_dir( - sourcedir, test_filename) + for test_filename in [ + msg_server_filename, + msg_local_filename_decrypted, + file_server_filename, + file_local_filename_decompressed, + file_local_filename_decrypted, + reply_server_filename, + reply_local_filename_decrypted, + ]: + abs_server_filename = add_test_file_to_temp_dir(sourcedir, test_filename) test_filename_absolute_paths.append(abs_server_filename) local_sources = [local_source] @@ -546,7 +602,7 @@ def test_update_files(homedir, mocker): * Local submission not returned by the remote server are deleted from the local database. """ - data_dir = os.path.join(homedir, 'data') + data_dir = os.path.join(homedir, "data") mock_session = mocker.MagicMock() # Source object related to the submissions. source = mocker.MagicMock() @@ -576,7 +632,8 @@ def test_update_files(homedir, mocker): local_source.id = 666 # };-) mock_session.query().filter_by().first.return_value = local_source mock_delete_submission_files = mocker.patch( - 'securedrop_client.storage.delete_single_submission_or_reply_on_disk') + "securedrop_client.storage.delete_single_submission_or_reply_on_disk" + ) update_files(remote_submissions, local_submissions, mock_session, data_dir) @@ -610,7 +667,7 @@ def test_update_messages(homedir, mocker): * Local messages not returned by the remote server are deleted from the local database. """ - data_dir = os.path.join(homedir, 'data') + data_dir = os.path.join(homedir, "data") mock_session = mocker.MagicMock() # Source object related to the submissions. source = mocker.MagicMock() @@ -642,9 +699,10 @@ def test_update_messages(homedir, mocker): local_user.id = 42 mock_session.query().filter_by().first.return_value = local_source mock_focu = mocker.MagicMock(return_value=local_user) - mocker.patch('securedrop_client.storage.find_or_create_user', mock_focu) + mocker.patch("securedrop_client.storage.find_or_create_user", mock_focu) mock_delete_submission_files = mocker.patch( - 'securedrop_client.storage.delete_single_submission_or_reply_on_disk') + "securedrop_client.storage.delete_single_submission_or_reply_on_disk" + ) update_messages(remote_messages, local_messages, mock_session, data_dir) @@ -679,7 +737,7 @@ def test_update_replies(homedir, mocker, session): local database. * References to journalist's usernames are correctly handled. """ - data_dir = os.path.join(homedir, 'data') + data_dir = os.path.join(homedir, "data") journalist = factory.User(id=1) session.add(journalist) @@ -699,10 +757,7 @@ def test_update_replies(homedir, mocker, session): ) session.add(local_reply_update) - local_reply_delete = factory.Reply( - source_id=source.id, - source=source, - ) + local_reply_delete = factory.Reply(source_id=source.id, source=source,) session.add(local_reply_delete) local_replies = [local_reply_update, local_reply_delete] @@ -751,7 +806,7 @@ def test_update_replies_cleanup_drafts(homedir, mocker, session): Check that draft replies are deleted if they correspond to a reply fetched from the server. """ - data_dir = os.path.join(homedir, 'data') + data_dir = os.path.join(homedir, "data") # Source object related to the submissions. source = factory.Source() user = factory.User() @@ -760,18 +815,28 @@ def test_update_replies_cleanup_drafts(homedir, mocker, session): session.commit() # One reply will exist on the server. - remote_reply_create = make_remote_reply(source.uuid, 'hehe') + remote_reply_create = make_remote_reply(source.uuid, "hehe") remote_replies = [remote_reply_create] # One draft reply will exist in the local database corresponding to the server reply. - draft_reply = db.DraftReply(uuid=remote_reply_create.uuid, source=source, journalist=user, - file_counter=3, timestamp=datetime.datetime(2000, 6, 6, 6, 0)) + draft_reply = db.DraftReply( + uuid=remote_reply_create.uuid, + source=source, + journalist=user, + file_counter=3, + timestamp=datetime.datetime(2000, 6, 6, 6, 0), + ) session.add(draft_reply) # Another draft reply will exist that should be moved to _after_ the new reply # once we confirm the previous reply. This ensures consistent ordering of interleaved # drafts (pending and failed) with replies, messages, and files from the user's perspective. - draft_reply_new = db.DraftReply(uuid='foo', source=source, journalist=user, - file_counter=3, timestamp=datetime.datetime(2001, 6, 6, 6, 0)) + draft_reply_new = db.DraftReply( + uuid="foo", + source=source, + journalist=user, + file_counter=3, + timestamp=datetime.datetime(2001, 6, 6, 6, 0), + ) session.add(draft_reply_new) session.commit() @@ -796,7 +861,7 @@ def test_update_replies_missing_source(homedir, mocker, session): """ Verify that a reply to an invalid source is handled. """ - data_dir = os.path.join(homedir, 'data') + data_dir = os.path.join(homedir, "data") journalist = factory.User(id=1) session.add(journalist) @@ -811,7 +876,7 @@ def test_update_replies_missing_source(homedir, mocker, session): remote_replies = [remote_reply] local_replies = [] - error_logger = mocker.patch('securedrop_client.storage.logger.error') + error_logger = mocker.patch("securedrop_client.storage.logger.error") update_replies(remote_replies, local_replies, session, data_dir) @@ -824,23 +889,22 @@ def test_find_or_create_user_existing_uuid(mocker): """ mock_session = mocker.MagicMock() mock_user = mocker.MagicMock() - mock_user.username = 'foobar' + 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 + assert find_or_create_user("uuid", "foobar", mock_session) == mock_user def test_find_or_create_user_update_username(mocker, session): """ Return an existing user object with the updated username. """ - user = factory.User(uuid='mock_uuid', username='mock_old_username') + user = factory.User(uuid="mock_uuid", username="mock_old_username") session.add(user) - actual_user = find_or_create_user('mock_uuid', 'mock_username', session) + actual_user = find_or_create_user("mock_uuid", "mock_username", session) assert actual_user == user - assert actual_user.username == 'mock_username' + assert actual_user.username == "mock_username" def test_find_or_create_user_new(mocker): @@ -849,8 +913,8 @@ def test_find_or_create_user_new(mocker): """ mock_session = mocker.MagicMock() 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' + 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() @@ -860,50 +924,45 @@ def test_update_and_get_user(mocker, session): Return an existing user object with the updated username. """ user = factory.User( - uuid='mock_uuid', - username='mock_username', # username is needed for find_or_create_user - firstname='mock_old_firstname', - lastname='mock_old_lastname') + uuid="mock_uuid", + username="mock_username", # username is needed for find_or_create_user + firstname="mock_old_firstname", + lastname="mock_old_lastname", + ) session.add(user) find_or_create_user_fn = mocker.patch( - 'securedrop_client.storage.find_or_create_user', return_value=user) + "securedrop_client.storage.find_or_create_user", return_value=user + ) actual_user = update_and_get_user( - uuid='mock_uuid', - username='mock_username', - firstname='mock_firstname', - lastname='mock_lastname', - session=session) + uuid="mock_uuid", + username="mock_username", + firstname="mock_firstname", + lastname="mock_lastname", + session=session, + ) - find_or_create_user_fn.assert_called_with('mock_uuid', 'mock_username', session) + find_or_create_user_fn.assert_called_with("mock_uuid", "mock_username", session) assert actual_user == user - assert actual_user.username == 'mock_username' - assert actual_user.firstname == 'mock_firstname' - assert actual_user.lastname == 'mock_lastname' + assert actual_user.username == "mock_username" + assert actual_user.firstname == "mock_firstname" + assert actual_user.lastname == "mock_lastname" def test_find_new_messages(mocker, session): source = factory.Source() message_not_downloaded = factory.Message( - source=source, - is_downloaded=False, - is_decrypted=None, - content=None) + source=source, is_downloaded=False, is_decrypted=None, content=None + ) message_decrypt_not_attempted = factory.Message( - source=source, - is_downloaded=True, - is_decrypted=None, - content=None) + source=source, is_downloaded=True, is_decrypted=None, content=None + ) message_decrypt_failed = factory.Message( - source=source, - is_downloaded=True, - is_decrypted=False, - content=None) + source=source, is_downloaded=True, is_decrypted=False, content=None + ) message_decrypt_success = factory.Message( - source=source, - is_downloaded=True, - is_decrypted=True, - content='teehee') + source=source, is_downloaded=True, is_decrypted=True, content="teehee" + ) session.add(source) session.add(message_decrypt_not_attempted) session.add(message_not_downloaded) @@ -924,10 +983,10 @@ def test_update_missing_files(mocker, homedir): file.is_downloaded = True files = [file] session.query().filter_by().all.return_value = files - data_dir = os.path.join(homedir, 'data') - mocker.patch('os.path.splitext', return_value=('mock_filename', 'dummy')) - mocker.patch('os.path.exists', return_value=False) - mark_as_not_downloaded_fn = mocker.patch('securedrop_client.storage.mark_as_not_downloaded') + data_dir = os.path.join(homedir, "data") + mocker.patch("os.path.splitext", return_value=("mock_filename", "dummy")) + mocker.patch("os.path.exists", return_value=False) + mark_as_not_downloaded_fn = mocker.patch("securedrop_client.storage.mark_as_not_downloaded") update_missing_files(data_dir, session) @@ -947,25 +1006,17 @@ def test_find_new_files(mocker, session): def test_find_new_replies(mocker, session): source = factory.Source() reply_not_downloaded = factory.Reply( - source=source, - is_downloaded=False, - is_decrypted=None, - content=None) + source=source, is_downloaded=False, is_decrypted=None, content=None + ) reply_decrypt_not_attempted = factory.Reply( - source=source, - is_downloaded=True, - is_decrypted=None, - content=None) + source=source, is_downloaded=True, is_decrypted=None, content=None + ) reply_decrypt_failed = factory.Reply( - source=source, - is_downloaded=True, - is_decrypted=False, - content=None) + source=source, is_downloaded=True, is_decrypted=False, content=None + ) reply_decrypt_success = factory.Reply( - source=source, - is_downloaded=True, - is_decrypted=True, - content='teehee') + source=source, is_downloaded=True, is_decrypted=True, content="teehee" + ) session.add(source) session.add(reply_decrypt_not_attempted) session.add(reply_not_downloaded) @@ -1001,31 +1052,32 @@ def test_set_file_decryption_status_with_content_false_to_true(mocker, session): def test_set_message_decryption_status_with_content_with_content(session, source): - ''' + """ It should be possible to set the decryption status of an object in the database to `True`. Additionally, if `content` is passed in, the `content` column of the DB should take that value. This is to ensure that we have a way to decrypt something without violating the condition: if is_decrypted then content is not none. - ''' + """ message = factory.Message( - source=source['source'], is_downloaded=True, is_decrypted=None, content=None) + source=source["source"], is_downloaded=True, is_decrypted=None, content=None + ) session.add(message) session.commit() - set_message_or_reply_content(type(message), message.uuid, 'mock_content', session) + set_message_or_reply_content(type(message), message.uuid, "mock_content", session) mark_as_decrypted(type(message), message.uuid, session) # requery to ensure new object message = session.query(db.Message).get(message.id) assert message.is_decrypted is True - assert message.content == 'mock_content' + assert message.content == "mock_content" def test_mark_file_as_not_downloaded(mocker): session = mocker.MagicMock() file = factory.File(source=factory.Source(), is_downloaded=True, is_decrypted=True) session.query().filter_by().one.return_value = file - mark_as_not_downloaded('mock_uuid', session) + mark_as_not_downloaded("mock_uuid", session) assert file.is_downloaded is False assert file.is_decrypted is None session.add.assert_called_once_with(file) @@ -1036,7 +1088,7 @@ def test_mark_file_as_downloaded(mocker): session = mocker.MagicMock() file = factory.File(source=factory.Source(), is_downloaded=False) session.query().filter_by().one.return_value = file - mark_as_downloaded(type(file), 'mock_uuid', session) + mark_as_downloaded(type(file), "mock_uuid", session) assert file.is_downloaded is True session.add.assert_called_once_with(file) session.commit.assert_called_once_with() @@ -1046,7 +1098,7 @@ def test_mark_message_as_downloaded(mocker): session = mocker.MagicMock() message = factory.Message(source=factory.Source(), is_downloaded=False) session.query().filter_by().one.return_value = message - mark_as_downloaded(type(message), 'mock_uuid', session) + mark_as_downloaded(type(message), "mock_uuid", session) assert message.is_downloaded is True session.add.assert_called_once_with(message) session.commit.assert_called_once_with() @@ -1056,7 +1108,7 @@ def test_mark_reply_as_downloaded(mocker): session = mocker.MagicMock() reply = factory.Reply(source=factory.Source(), is_downloaded=False) session.query().filter_by().one.return_value = reply - mark_as_downloaded(type(reply), 'mock_uuid', session) + mark_as_downloaded(type(reply), "mock_uuid", session) assert reply.is_downloaded is True session.add.assert_called_once_with(reply) session.commit.assert_called_once_with() @@ -1070,10 +1122,10 @@ def test_delete_single_submission_or_reply_race_guard(homedir, mocker): """ test_obj = mocker.MagicMock() - test_obj.filename = '1-dissolved-steak-msg.gpg' + test_obj.filename = "1-dissolved-steak-msg.gpg" add_test_file_to_temp_dir(homedir, test_obj.filename) - mock_remove = mocker.patch('os.remove', side_effect=FileNotFoundError) + mock_remove = mocker.patch("os.remove", side_effect=FileNotFoundError) delete_single_submission_or_reply_on_disk(test_obj, homedir) mock_remove.call_count == 1 @@ -1086,10 +1138,14 @@ def test_delete_single_submission_or_reply_single_file(homedir, mocker): """ source = factory.Source(journalist_designation="dissolved-steak") - file_server_filename = '1-dissolved-steak-msg.gpg' + file_server_filename = "1-dissolved-steak-msg.gpg" test_obj = db.File( - source=source, uuid="test", size=123, filename=file_server_filename, - download_url='http://test/test') + source=source, + uuid="test", + size=123, + filename=file_server_filename, + download_url="http://test/test", + ) source_directory = os.path.dirname(test_obj.location(homedir)) add_test_file_to_temp_dir(source_directory, file_server_filename) @@ -1097,10 +1153,10 @@ def test_delete_single_submission_or_reply_single_file(homedir, mocker): # Ensure both file and its containing folder are gone. with pytest.raises(FileNotFoundError): - open(os.path.join(source_directory, file_server_filename), 'r') + open(os.path.join(source_directory, file_server_filename), "r") with pytest.raises(FileNotFoundError): - open(source_directory, 'r') + open(source_directory, "r") def test_delete_single_submission_or_reply_single_file_no_folder(homedir, mocker): @@ -1110,43 +1166,47 @@ def test_delete_single_submission_or_reply_single_file_no_folder(homedir, mocker """ source = factory.Source(journalist_designation="dissolved-steak") - file_server_filename = '1-dissolved-steak-msg.gpg' + file_server_filename = "1-dissolved-steak-msg.gpg" test_obj = db.File( - source=source, uuid="test", size=123, filename=file_server_filename, - download_url='http://test/test') + source=source, + uuid="test", + size=123, + filename=file_server_filename, + download_url="http://test/test", + ) original_location = test_obj.location test_obj.location = mocker.MagicMock( - side_effect=[os.path.join(homedir, file_server_filename), - original_location(homedir)]) + side_effect=[os.path.join(homedir, file_server_filename), original_location(homedir)] + ) add_test_file_to_temp_dir(homedir, file_server_filename) delete_single_submission_or_reply_on_disk(test_obj, homedir) with pytest.raises(FileNotFoundError): - open(os.path.join(homedir, file_server_filename), 'r') + open(os.path.join(homedir, file_server_filename), "r") def test_source_exists_true(homedir, mocker): - ''' + """ Check that method returns True if a source is return from the query. - ''' + """ session = mocker.MagicMock() source = factory.RemoteSource() - source.uuid = 'test-source-uuid' + source.uuid = "test-source-uuid" session.query().filter_by().one.return_value = source - assert source_exists(session, 'test-source-uuid') + assert source_exists(session, "test-source-uuid") def test_source_exists_false(homedir, mocker): - ''' + """ Check that method returns False if NoResultFound is thrown when we try to query the source. - ''' + """ session = mocker.MagicMock() source = mocker.MagicMock() - source.uuid = 'test-source-uuid' + source.uuid = "test-source-uuid" session.query().filter_by().one.side_effect = NoResultFound() - assert not source_exists(session, 'test-source-uuid') + assert not source_exists(session, "test-source-uuid") def test_get_file(mocker, session): @@ -1182,15 +1242,17 @@ def test_get_reply(mocker, session): assert result == reply -def test_pending_replies_are_marked_as_failed_on_logout_login(mocker, session, - reply_status_codes): +def test_pending_replies_are_marked_as_failed_on_logout_login(mocker, session, reply_status_codes): source = factory.Source() - pending_status = session.query(db.ReplySendStatus).filter_by( - name=db.ReplySendStatusCodes.PENDING.value).one() - failed_status = session.query(db.ReplySendStatus).filter_by( - name=db.ReplySendStatusCodes.FAILED.value).one() - pending_draft_reply = factory.DraftReply(source=source, - send_status=pending_status) + pending_status = ( + session.query(db.ReplySendStatus) + .filter_by(name=db.ReplySendStatusCodes.PENDING.value) + .one() + ) + failed_status = ( + session.query(db.ReplySendStatus).filter_by(name=db.ReplySendStatusCodes.FAILED.value).one() + ) + pending_draft_reply = factory.DraftReply(source=source, send_status=pending_status) session.add(source) session.add(pending_draft_reply) @@ -1207,11 +1269,11 @@ def test_update_file_size(homedir, session): session.commit() real_size = 2112 - data_dir = os.path.join(homedir, 'data') + data_dir = os.path.join(homedir, "data") file_location = f.location(data_dir) os.makedirs(os.path.dirname(file_location), mode=0o700, exist_ok=True) - with open(file_location, mode='w') as f1: + with open(file_location, mode="w") as f1: f1.write("x" * real_size) update_file_size(f.uuid, data_dir, session) diff --git a/tests/test_sync.py b/tests/test_sync.py index dbe430157..c9251f485 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,5 +1,4 @@ import pytest - from sdclientapi import RequestTimeoutError, ServerConnectionError from securedrop_client.api_jobs.base import ApiInaccessibleError @@ -7,17 +6,17 @@ def test_ApiSync_init(mocker, session_maker, homedir): - ''' + """ Ensure sync thread is not started in the constructor. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) assert not api_sync.sync_thread.isRunning() def test_ApiSync_start(mocker, session_maker, homedir): - ''' + """ Ensure sync thread starts when start is called and not already running. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) api_sync.sync_thread = mocker.MagicMock() api_sync.sync_thread.isRunning = mocker.MagicMock(return_value=False) @@ -28,9 +27,9 @@ def test_ApiSync_start(mocker, session_maker, homedir): def test_ApiSync_start_not_called_when_already_started(mocker, session_maker, homedir): - ''' + """ Ensure sync thread does not start when start is called if already running. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) api_sync.sync_thread = mocker.MagicMock() api_sync.sync_thread.isRunning = mocker.MagicMock(return_value=True) @@ -41,9 +40,9 @@ def test_ApiSync_start_not_called_when_already_started(mocker, session_maker, ho def test_ApiSync_stop(mocker, session_maker, homedir): - ''' + """ Ensure thread is not running when stopped and api_client is None. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) api_sync.stop() @@ -53,9 +52,9 @@ def test_ApiSync_stop(mocker, session_maker, homedir): def test_ApiSync_stop_calls_quit(mocker, session_maker, homedir): - ''' + """ Ensure stop calls QThread's quit method and api_client is None. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) api_sync.sync_thread = mocker.MagicMock() api_sync.sync_thread.isRunning = mocker.MagicMock(return_value=True) @@ -67,13 +66,13 @@ def test_ApiSync_stop_calls_quit(mocker, session_maker, homedir): def test_ApiSyncBackgroundTask_sync(mocker, session_maker, homedir): - ''' + """ Ensure sync enqueues a MetadataSyncJob and calls it's parent's processing function - ''' + """ api_client = mocker.MagicMock() api_sync = ApiSync(api_client, session_maker, mocker.MagicMock(), homedir) - sync_started = mocker.patch.object(api_sync.api_sync_bg_task, 'sync_started') - _do_call_api_fn = mocker.patch('securedrop_client.sync.MetadataSyncJob._do_call_api') + sync_started = mocker.patch.object(api_sync.api_sync_bg_task, "sync_started") + _do_call_api_fn = mocker.patch("securedrop_client.sync.MetadataSyncJob._do_call_api") api_sync.api_sync_bg_task.sync() @@ -82,9 +81,9 @@ def test_ApiSyncBackgroundTask_sync(mocker, session_maker, homedir): def test_ApiSyncBackgroundTask_sync_resets_retries(mocker, session_maker, homedir): - ''' + """ Ensure sync enqueues a MetadataSyncJob and calls it's parent's processing function - ''' + """ api_client = mocker.MagicMock() api_sync = ApiSync(api_client, session_maker, mocker.MagicMock(), homedir) @@ -96,18 +95,19 @@ def test_ApiSyncBackgroundTask_sync_resets_retries(mocker, session_maker, homedi def test_ApiSyncBackgroundTask_sync_catches_ApiInaccessibleError(mocker, session_maker, homedir): - ''' + """ Ensure sync calls the parent processing function of MetadataSyncJob, catches ApiInaccessibleError exception, and emits failure signal. - ''' + """ api_client = mocker.MagicMock() api_sync = ApiSync(api_client, session_maker, mocker.MagicMock(), homedir) - sync_started = mocker.patch.object(api_sync.api_sync_bg_task, 'sync_started') - success_signal = mocker.patch('securedrop_client.sync.MetadataSyncJob.success_signal') - failure_signal = mocker.patch('securedrop_client.sync.MetadataSyncJob.failure_signal') + sync_started = mocker.patch.object(api_sync.api_sync_bg_task, "sync_started") + success_signal = mocker.patch("securedrop_client.sync.MetadataSyncJob.success_signal") + failure_signal = mocker.patch("securedrop_client.sync.MetadataSyncJob.failure_signal") error = ApiInaccessibleError() _do_call_api_fn = mocker.patch( - 'securedrop_client.sync.MetadataSyncJob._do_call_api', side_effect=error) + "securedrop_client.sync.MetadataSyncJob._do_call_api", side_effect=error + ) api_sync.api_sync_bg_task.sync() @@ -118,18 +118,17 @@ def test_ApiSyncBackgroundTask_sync_catches_ApiInaccessibleError(mocker, session def test_ApiSyncBackgroundTask_sync_catches_all_other_exceptions(mocker, session_maker, homedir): - ''' + """ Ensure sync calls the parent processing function of MetadataSyncJob, catches all exceptions, and emits failure signal. - ''' + """ api_client = mocker.MagicMock() api_sync = ApiSync(api_client, session_maker, mocker.MagicMock(), homedir) - sync_started = mocker.patch.object(api_sync.api_sync_bg_task, 'sync_started') - success_signal = mocker.patch('securedrop_client.sync.MetadataSyncJob.success_signal') - failure_signal = mocker.patch('securedrop_client.sync.MetadataSyncJob.failure_signal') + sync_started = mocker.patch.object(api_sync.api_sync_bg_task, "sync_started") + success_signal = mocker.patch("securedrop_client.sync.MetadataSyncJob.success_signal") + failure_signal = mocker.patch("securedrop_client.sync.MetadataSyncJob.failure_signal") error = Exception() - call_api_fn = mocker.patch( - 'securedrop_client.sync.MetadataSyncJob.call_api', side_effect=error) + call_api_fn = mocker.patch("securedrop_client.sync.MetadataSyncJob.call_api", side_effect=error) api_sync.api_sync_bg_task.sync() @@ -140,12 +139,12 @@ def test_ApiSyncBackgroundTask_sync_catches_all_other_exceptions(mocker, session def test_ApiSync_on_sync_success(mocker, session_maker, homedir): - ''' + """ Ensure success handler emits success signal that the Controller links to and fires another sync after a supplied amount of time. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) - sync_success = mocker.patch.object(api_sync, 'sync_success') + sync_success = mocker.patch.object(api_sync, "sync_success") api_sync.on_sync_success() @@ -153,13 +152,13 @@ def test_ApiSync_on_sync_success(mocker, session_maker, homedir): def test_ApiSync_on_sync_failure(mocker, session_maker, homedir): - ''' + """ Ensure failure handler emits failure signal that the Controller links to and does not fire another sync for errors other than RequestTimeoutError or ServerConnectionError - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) - sync_failure = mocker.patch.object(api_sync, 'sync_failure') - singleShot_fn = mocker.patch('securedrop_client.sync.QTimer.singleShot') + sync_failure = mocker.patch.object(api_sync, "sync_failure") + singleShot_fn = mocker.patch("securedrop_client.sync.QTimer.singleShot") error = Exception() @@ -171,14 +170,14 @@ def test_ApiSync_on_sync_failure(mocker, session_maker, homedir): @pytest.mark.parametrize("exception", [RequestTimeoutError, ServerConnectionError]) def test_ApiSync_on_sync_failure_because_of_timeout(mocker, session_maker, homedir, exception): - ''' + """ Ensure failure handler emits failure signal that the Controller links to and sets up timer to fire another sync after 15 seconds if the failure reason is a RequestTimeoutError or ServerConnectionError. - ''' + """ api_sync = ApiSync(mocker.MagicMock(), session_maker, mocker.MagicMock(), homedir) - sync_failure = mocker.patch.object(api_sync, 'sync_failure') - singleShot_fn = mocker.patch('securedrop_client.sync.QTimer.singleShot') + sync_failure = mocker.patch.object(api_sync, "sync_failure") + singleShot_fn = mocker.patch("securedrop_client.sync.QTimer.singleShot") error = exception() api_sync.on_sync_failure(error) diff --git a/tests/test_utils.py b/tests/test_utils.py index 1acf52844..b9af07d73 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,12 +1,13 @@ import pytest -from securedrop_client.utils import safe_mkdir, humanize_filesize + +from securedrop_client.utils import humanize_filesize, safe_mkdir def test_safe_makedirs_non_absolute(homedir): with pytest.raises(ValueError) as e_info: - safe_mkdir(homedir, '..') + safe_mkdir(homedir, "..") - assert 'not absolute' in str(e_info.value) + assert "not absolute" in str(e_info.value) def test_humanize_file_size_bytes(): From bba9f9df31ed96b4706ea08dd23d8631a90e93bf Mon Sep 17 00:00:00 2001 From: John Hensley Date: Tue, 23 Jun 2020 18:12:48 -0400 Subject: [PATCH 4/4] Add .git-blame-ignore-revs --- .git-blame-ignore-revs | 1 + 1 file changed, 1 insertion(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..1333a9510 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +361ba8821de684b077dcad34bea3d16df246a8c4 \ No newline at end of file