diff --git a/.ds.baseline b/.ds.baseline index 049c580e4..8b09a97f4 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -161,7 +161,7 @@ "filename": "app/config.py", "hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc", "is_verified": false, - "line_number": 121, + "line_number": 119, "is_secret": false } ], @@ -634,5 +634,5 @@ } ] }, - "generated_at": "2025-07-29T21:32:59Z" + "generated_at": "2025-07-31T17:38:47Z" } diff --git a/app/__init__.py b/app/__init__.py index e37aec853..2d574b13f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -185,7 +185,7 @@ def _csp(config): def create_app(application): @application.after_request def add_security_headers(response): - response.headers['Cross-Origin-Embedder-Policy'] = 'credentialless' + response.headers["Cross-Origin-Embedder-Policy"] = "credentialless" return response @application.context_processor @@ -273,11 +273,11 @@ def create_app(application): content_security_policy=_csp(application.config), content_security_policy_nonce_in=["style-src", "script-src"], permissions_policy={ - "accelerometer": "(self \"https://www.youtube-nocookie.com\")", - "autoplay": "(self \"https://www.youtube-nocookie.com\")", + "accelerometer": '(self "https://www.youtube-nocookie.com")', + "autoplay": '(self "https://www.youtube-nocookie.com")', "camera": "()", "geolocation": "()", - "gyroscope": "(self \"https://www.youtube-nocookie.com\")", + "gyroscope": '(self "https://www.youtube-nocookie.com")', "local-fonts": "()", "magnetometer": "()", "microphone": "()", @@ -543,7 +543,7 @@ def register_errorhandlers(application): # noqa (C901 too complex) application.logger.warning( f"API {error_url} failed with status {error.status_code} message {error.message}", exc_info=sys.exc_info(), - stack_info=True + stack_info=True, ) error_code = error.status_code @@ -555,7 +555,7 @@ def register_errorhandlers(application): # noqa (C901 too complex) application.logger.exception( f"API {error_url} failed with status {error.status_code} message {error.message}", exc_info=sys.exc_info(), - stack_info=True + stack_info=True, ) error_code = 500 diff --git a/app/config.py b/app/config.py index 48d966cd8..58e162148 100644 --- a/app/config.py +++ b/app/config.py @@ -16,9 +16,7 @@ class Config(object): API_PUBLIC_WS_URL = getenv("API_PUBLIC_WS_URL", "localhost") ADMIN_BASE_URL = getenv("ADMIN_BASE_URL", "http://localhost:6012") - HEADER_COLOUR = ( - "#81878b" # mix of dark-grey and mid-grey - ) + HEADER_COLOUR = "#81878b" # mix of dark-grey and mid-grey ASSETS_DEBUG = False diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py index 686672f2b..92cb94982 100644 --- a/app/main/views/manage_users.py +++ b/app/main/views/manage_users.py @@ -73,7 +73,9 @@ def invite_user(service_id, user_id=None): else: user_to_invite = None - service_has_email_auth = current_service.has_permission(ServicePermission.EMAIL_AUTH) + service_has_email_auth = current_service.has_permission( + ServicePermission.EMAIL_AUTH + ) if not service_has_email_auth: form.login_authentication.data = "sms_auth" @@ -116,7 +118,9 @@ def invite_user(service_id, user_id=None): @main.route("/services//users/", methods=["GET", "POST"]) @user_has_permissions(ServicePermission.MANAGE_SERVICE) def edit_user_permissions(service_id, user_id): - service_has_email_auth = current_service.has_permission(ServicePermission.EMAIL_AUTH) + service_has_email_auth = current_service.has_permission( + ServicePermission.EMAIL_AUTH + ) user = current_service.get_team_member(user_id) mobile_number = None diff --git a/app/main/views/notifications.py b/app/main/views/notifications.py index 0ad680d1c..59390741d 100644 --- a/app/main/views/notifications.py +++ b/app/main/views/notifications.py @@ -94,7 +94,9 @@ def view_notification(service_id, notification_id, error_message=None): updated_at=notification["sent_at"], help=get_help_argument(), notification_id=notification["id"], - can_receive_inbound=(current_service.has_permission(ServicePermission.INBOUND_SMS)), + can_receive_inbound=( + current_service.has_permission(ServicePermission.INBOUND_SMS) + ), sent_with_test_key=(notification.get("key_type") == KEY_TYPE_TEST), back_link=back_link, ) diff --git a/app/main/views/send.py b/app/main/views/send.py index 3294d0909..a1c4a9788 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -560,7 +560,9 @@ def _check_messages(service_id, template_id, upload_id, preview_row, **kwargs): max_errors_shown=50, guestlist=allow_list, remaining_messages=remaining_messages, - allow_international_sms=current_service.has_permission(ServicePermission.INTERNATIONAL_SMS), + allow_international_sms=current_service.has_permission( + ServicePermission.INTERNATIONAL_SMS + ), ) if request.args.get("from_test"): diff --git a/app/utils/user_permissions.py b/app/utils/user_permissions.py index b221fbb18..d543b2358 100644 --- a/app/utils/user_permissions.py +++ b/app/utils/user_permissions.py @@ -4,7 +4,10 @@ from app.enums import ServicePermission permission_mappings = { # TODO: consider turning off email-sending permissions during SMS pilot - ServicePermission.SEND_MESSAGES: [ServicePermission.SEND_TEXTS, ServicePermission.SEND_EMAILS], + ServicePermission.SEND_MESSAGES: [ + ServicePermission.SEND_TEXTS, + ServicePermission.SEND_EMAILS, + ], ServicePermission.MANAGE_TEMPLATES: [ServicePermission.MANAGE_TEMPLATES], ServicePermission.MANAGE_SERVICE: [ ServicePermission.MANAGE_USERS, diff --git a/notifications_python_client/base.py b/notifications_python_client/base.py index 6312416ea..9c010118a 100644 --- a/notifications_python_client/base.py +++ b/notifications_python_client/base.py @@ -22,9 +22,7 @@ class BaseAPIClient: This class is not thread-safe. """ - def __init__( - self, api_key, base_url=API_PUBLIC_URL, timeout=30 - ): + def __init__(self, api_key, base_url=API_PUBLIC_URL, timeout=30): """ Initialise the client Error if either of base_url or secret missing diff --git a/tests/app/main/views/service_settings/test_service_setting_permissions.py b/tests/app/main/views/service_settings/test_service_setting_permissions.py index ba8c522e1..0307b680a 100644 --- a/tests/app/main/views/service_settings/test_service_setting_permissions.py +++ b/tests/app/main/views/service_settings/test_service_setting_permissions.py @@ -202,7 +202,11 @@ def test_service_setting_link_toggles_index_error( ("permissions", "permissions_text", "visible"), [ ("sms", "inbound SMS", True), - (ServicePermission.INBOUND_SMS, "inbound SMS", False), # no sms parent permission + ( + ServicePermission.INBOUND_SMS, + "inbound SMS", + False, + ), # no sms parent permission # also test no permissions set ("", "inbound SMS", False), ], diff --git a/tests/app/main/views/test_activity.py b/tests/app/main/views/test_activity.py index b9dc3d94a..3f4e8264b 100644 --- a/tests/app/main/views/test_activity.py +++ b/tests/app/main/views/test_activity.py @@ -295,10 +295,12 @@ def test_download_links_show_when_data_available( mock_jobs_with_data = { "data": [{"id": "job1", "created_at": "2020-01-01T00:00:00.000000+00:00"}], "total": 1, - "page_size": 50 + "page_size": 50, } - mocker.patch("app.job_api_client.get_page_of_jobs", return_value=mock_jobs_with_data) + mocker.patch( + "app.job_api_client.get_page_of_jobs", return_value=mock_jobs_with_data + ) mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[{"id": "job1"}]) page = client_request.get( @@ -323,7 +325,7 @@ def test_download_links_partial_data_available( mock_jobs_with_data = { "data": [{"id": "job1", "created_at": "2020-01-01T00:00:00.000000+00:00"}], "total": 1, - "page_size": 50 + "page_size": 50, } mock_jobs_empty = {"data": [], "total": 0, "page_size": 50} @@ -332,7 +334,9 @@ def test_download_links_partial_data_available( return mock_jobs_with_data return mock_jobs_empty - mocker.patch("app.job_api_client.get_page_of_jobs", side_effect=mock_get_page_of_jobs) + mocker.patch( + "app.job_api_client.get_page_of_jobs", side_effect=mock_get_page_of_jobs + ) mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[]) page = client_request.get( @@ -369,7 +373,10 @@ def test_download_links_no_data_available( assert "Download all data last 3 days" not in page.text assert "Download all data last 5 days" not in page.text assert "Download all data last 7 days" not in page.text - assert "No recent activity to download. Download links will appear when jobs are available." in page.text + assert ( + "No recent activity to download. Download links will appear when jobs are available." + in page.text + ) def test_download_not_available_to_users_without_dashboard( @@ -560,9 +567,8 @@ def test_should_show_notifications_for_a_service_with_next_previous( ): mocker.patch( "app.notification_api_client.get_notifications_for_service", - return_value=notification_json( - service_one["id"], rows=50, with_links=True - ) | {"total": 150}, + return_value=notification_json(service_one["id"], rows=50, with_links=True) + | {"total": 150}, ) page = client_request.get( "main.view_notifications", @@ -608,9 +614,8 @@ def test_doesnt_show_next_button_on_last_page( ): mocker.patch( "app.notification_api_client.get_notifications_for_service", - return_value=notification_json( - service_one["id"], rows=50, with_links=True - ) | {"total": 100}, + return_value=notification_json(service_one["id"], rows=50, with_links=True) + | {"total": 100}, ) page = client_request.get( "main.view_notifications", @@ -637,9 +642,7 @@ def test_doesnt_show_pagination_when_50_or_fewer_items( ): mocker.patch( "app.notification_api_client.get_notifications_for_service", - return_value=notification_json( - service_one["id"], rows=50, with_links=False - ), + return_value=notification_json(service_one["id"], rows=50, with_links=False), ) page = client_request.get( "main.view_notifications", @@ -663,9 +666,8 @@ def test_doesnt_show_pagination_with_search_term( ): mocker.patch( "app.notification_api_client.get_notifications_for_service", - return_value=notification_json( - service_one["id"], rows=50, with_links=True - ) | {"total": 100}, + return_value=notification_json(service_one["id"], rows=50, with_links=True) + | {"total": 100}, ) page = client_request.post( "main.view_notifications", diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index f468e6e34..5d0b040e9 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -966,7 +966,7 @@ def test_menu_manage_service( ServicePermission.VIEW_ACTIVITY, ServicePermission.MANAGE_TEMPLATES, ServicePermission.MANAGE_USERS, - ServicePermission.MANAGE_SETTINGS + ServicePermission.MANAGE_SETTINGS, ], ) page = str(page) diff --git a/tests/app/main/views/test_jobs_activity.py b/tests/app/main/views/test_jobs_activity.py index ff1e7080d..c2a6bcd3e 100644 --- a/tests/app/main/views/test_jobs_activity.py +++ b/tests/app/main/views/test_jobs_activity.py @@ -50,9 +50,7 @@ def test_all_activity( mock_get_page_of_jobs = mocker.patch( "app.job_api_client.get_page_of_jobs", return_value=MOCK_JOBS ) - mocker.patch( - "app.job_api_client.get_immediate_jobs", return_value=[] - ) + mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[]) response = client_request.get_response( "main.all_jobs_activity", @@ -65,7 +63,7 @@ def test_all_activity( assert "All activity" in response.text assert any( - call[0][0] == SERVICE_ONE_ID and call[1].get('page') == current_page + call[0][0] == SERVICE_ONE_ID and call[1].get("page") == current_page for call in mock_get_page_of_jobs.call_args_list ) page = BeautifulSoup(response.data, "html.parser") @@ -139,9 +137,7 @@ def test_all_activity_no_jobs(client_request, mocker): "total": 0, }, ) - mocker.patch( - "app.job_api_client.get_immediate_jobs", return_value=[] - ) + mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[]) response = client_request.get_response( "main.all_jobs_activity", service_id=SERVICE_ONE_ID, @@ -162,7 +158,7 @@ def test_all_activity_no_jobs(client_request, mocker): expected_message == actual_message ), f"Expected message '{expected_message}', but got '{actual_message}'" assert any( - call[0][0] == SERVICE_ONE_ID and call[1].get('page') == current_page + call[0][0] == SERVICE_ONE_ID and call[1].get("page") == current_page for call in mock_get_page_of_jobs.call_args_list ) @@ -194,9 +190,7 @@ def test_all_activity_pagination(client_request, mocker): "total": 100, }, ) - mocker.patch( - "app.job_api_client.get_immediate_jobs", return_value=[] - ) + mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[]) response = client_request.get_response( "main.all_jobs_activity", @@ -204,7 +198,7 @@ def test_all_activity_pagination(client_request, mocker): page=current_page, ) assert any( - call[0][0] == SERVICE_ONE_ID and call[1].get('page') == current_page + call[0][0] == SERVICE_ONE_ID and call[1].get("page") == current_page for call in mock_get_page_of_jobs.call_args_list ) diff --git a/tests/app/test_zap_security_fixes.py b/tests/app/test_zap_security_fixes.py index 46e2387cb..433e7f88d 100644 --- a/tests/app/test_zap_security_fixes.py +++ b/tests/app/test_zap_security_fixes.py @@ -1,45 +1,52 @@ -import pytest - - -def test_csp_no_unsafe_eval(client_request, mocker, mock_get_service_and_organization_counts): +def test_csp_no_unsafe_eval( + client_request, mocker, mock_get_service_and_organization_counts +): """Check that unsafe-eval was removed from CSP""" mocker.patch("app.notify_client.user_api_client.UserApiClient.deactivate_user") client_request.logout() - response = client_request.get_response('.index') - csp = response.headers.get('Content-Security-Policy', '') + response = client_request.get_response(".index") + csp = response.headers.get("Content-Security-Policy", "") assert "'unsafe-eval'" not in csp -def test_no_duplicate_form_action(client_request, mocker, mock_get_service_and_organization_counts): +def test_no_duplicate_form_action( + client_request, mocker, mock_get_service_and_organization_counts +): """Check that form-action only appears once in CSP""" mocker.patch("app.notify_client.user_api_client.UserApiClient.deactivate_user") client_request.logout() - response = client_request.get_response('.index') - csp = response.headers.get('Content-Security-Policy', '') + response = client_request.get_response(".index") + csp = response.headers.get("Content-Security-Policy", "") # Count how many times form-action appears - count = csp.count('form-action') + count = csp.count("form-action") assert count == 1 -def test_cross_origin_embedder_policy_set_to_credentialless(client_request, mocker, mock_get_service_and_organization_counts): +def test_cross_origin_embedder_policy_set_to_credentialless( + client_request, mocker, mock_get_service_and_organization_counts +): """Check that Cross-Origin-Embedder-Policy is set to 'credentialless' for YouTube compatibility""" mocker.patch("app.notify_client.user_api_client.UserApiClient.deactivate_user") client_request.logout() - response = client_request.get_response('.index') + response = client_request.get_response(".index") - assert response.headers.get('Cross-Origin-Embedder-Policy') == 'credentialless' + assert response.headers.get("Cross-Origin-Embedder-Policy") == "credentialless" -def test_permissions_policy_allows_youtube_features(client_request, mocker, mock_get_service_and_organization_counts): +def test_permissions_policy_allows_youtube_features( + client_request, mocker, mock_get_service_and_organization_counts +): """Check that Permissions-Policy allows necessary features for YouTube embeds""" mocker.patch("app.notify_client.user_api_client.UserApiClient.deactivate_user") client_request.logout() - response = client_request.get_response('.index') + response = client_request.get_response(".index") - permissions_policy = response.headers.get('Permissions-Policy', '') + permissions_policy = response.headers.get("Permissions-Policy", "") - assert 'accelerometer=(self "https://www.youtube-nocookie.com")' in permissions_policy + assert ( + 'accelerometer=(self "https://www.youtube-nocookie.com")' in permissions_policy + ) assert 'autoplay=(self "https://www.youtube-nocookie.com")' in permissions_policy assert 'gyroscope=(self "https://www.youtube-nocookie.com")' in permissions_policy diff --git a/tests/app/utils/test_user.py b/tests/app/utils/test_user.py index a4d97f6db..47c761ec8 100644 --- a/tests/app/utils/test_user.py +++ b/tests/app/utils/test_user.py @@ -36,7 +36,11 @@ def test_permissions( request.view_args.update({"service_id": "foo"}) api_user_active["permissions"] = { - "foo": [ServicePermission.MANAGE_USERS, ServicePermission.MANAGE_TEMPLATES, ServicePermission.MANAGE_SETTINGS] + "foo": [ + ServicePermission.MANAGE_USERS, + ServicePermission.MANAGE_TEMPLATES, + ServicePermission.MANAGE_SETTINGS, + ] } api_user_active["services"] = ["foo", "bar"] @@ -66,7 +70,11 @@ def test_permissions_forbidden( request.view_args.update({"service_id": "foo"}) api_user_active["permissions"] = { - "foo": [ServicePermission.MANAGE_USERS, ServicePermission.MANAGE_TEMPLATES, ServicePermission.MANAGE_SETTINGS] + "foo": [ + ServicePermission.MANAGE_USERS, + ServicePermission.MANAGE_TEMPLATES, + ServicePermission.MANAGE_SETTINGS, + ] } api_user_active["services"] = ["foo", "bar"] @@ -179,7 +187,11 @@ def test_user_with_no_permissions_to_service_goes_to_templates( api_user_active, ): api_user_active["permissions"] = { - "foo": [ServicePermission.MANAGE_USERS, ServicePermission.MANAGE_TEMPLATES, ServicePermission.MANAGE_SETTINGS] + "foo": [ + ServicePermission.MANAGE_USERS, + ServicePermission.MANAGE_TEMPLATES, + ServicePermission.MANAGE_SETTINGS, + ] } api_user_active["services"] = ["foo", "bar"] client_request.login(api_user_active)