From a56dd9014ab872efa26451b6036edb7741e6e9ec Mon Sep 17 00:00:00 2001 From: Lex Alexander Date: Tue, 5 May 2026 10:28:52 -0700 Subject: [PATCH 1/5] feat: Ensure service account file exists before initializing credentials --- pyfcm/baseapi.py | 8 ++++++++ tests/test_fcm.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index 8ece016..d4dba03 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -8,6 +8,7 @@ import requests from requests.adapters import HTTPAdapter from urllib3 import Retry +from os import path from google.oauth2 import service_account from google.oauth2.credentials import Credentials @@ -177,6 +178,13 @@ def _initialize_credentials(self): Initialize credentials and FCM endpoint if not already initialized. """ if self.credentials is None: + + missing_account_file = not path.isfile(self._service_account_file) + + if missing_account_file: + raise InvalidDataError(f"The service account file you passed does not exist at '{path}'. " + "Ensure it does not have any typos and exists." + ) self.credentials = service_account.Credentials.from_service_account_file( self._service_account_file, scopes=["https://www.googleapis.com/auth/firebase.messaging"], diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 1c1284d..b7dfce3 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -8,6 +8,14 @@ def test_push_service_without_credentials(): except errors.AuthenticationError: pass +def test_push_service_with_incorrect_service_account_file(): + try: + # figure out why this goddamn test is not running + fcm = FCMNotification(service_account_file='./foo.json', project_id=None, credentials=None) + fcm.notify() + assert False, "Should raise InvalidDataError without correct service account file path" + except errors.InvalidDataError: + pass def test_push_service_directly_passed_credentials(push_service): # We should infer the project ID/endpoint from credentials From 9fea43ea62708a4d3bae53b7eea060f94ee7620a Mon Sep 17 00:00:00 2001 From: Lex Alexander Date: Fri, 15 May 2026 20:25:05 -0700 Subject: [PATCH 2/5] Remove comment and use service account file as path --- pyfcm/baseapi.py | 10 +++------- tests/test_fcm.py | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index d4dba03..071d2de 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -10,7 +10,6 @@ from urllib3 import Retry from os import path -from google.oauth2 import service_account from google.oauth2.credentials import Credentials import google.auth.transport.requests @@ -178,14 +177,11 @@ def _initialize_credentials(self): Initialize credentials and FCM endpoint if not already initialized. """ if self.credentials is None: - - missing_account_file = not path.isfile(self._service_account_file) - - if missing_account_file: - raise InvalidDataError(f"The service account file you passed does not exist at '{path}'. " + if not path.isfile(self._service_account_file): + raise InvalidDataError(f"The service account file you passed does not exist at '{self._service_account_file}'. " "Ensure it does not have any typos and exists." ) - self.credentials = service_account.Credentials.from_service_account_file( + self.credentials = Credentials.from_service_account_file( self._service_account_file, scopes=["https://www.googleapis.com/auth/firebase.messaging"], ) diff --git a/tests/test_fcm.py b/tests/test_fcm.py index b7dfce3..19b2708 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -10,7 +10,6 @@ def test_push_service_without_credentials(): def test_push_service_with_incorrect_service_account_file(): try: - # figure out why this goddamn test is not running fcm = FCMNotification(service_account_file='./foo.json', project_id=None, credentials=None) fcm.notify() assert False, "Should raise InvalidDataError without correct service account file path" From c4fb45f19e29d75dd971221421cee8abf5a717d9 Mon Sep 17 00:00:00 2001 From: Lex Alexander Date: Sat, 16 May 2026 04:25:50 -0700 Subject: [PATCH 3/5] Include regression test from Niccari's branch and bring back service_account --- pyfcm/baseapi.py | 4 ++-- tests/test_fcm.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index 071d2de..fab80de 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -9,7 +9,7 @@ from requests.adapters import HTTPAdapter from urllib3 import Retry from os import path - +from google.oauth2 import service_account from google.oauth2.credentials import Credentials import google.auth.transport.requests @@ -181,7 +181,7 @@ def _initialize_credentials(self): raise InvalidDataError(f"The service account file you passed does not exist at '{self._service_account_file}'. " "Ensure it does not have any typos and exists." ) - self.credentials = Credentials.from_service_account_file( + self.credentials = service_account.Credentials.from_service_account_file( self._service_account_file, scopes=["https://www.googleapis.com/auth/firebase.messaging"], ) diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 19b2708..396ba52 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -16,6 +16,26 @@ def test_push_service_with_incorrect_service_account_file(): except errors.InvalidDataError: pass +def test_push_service_with_valid_service_account_file(mocker): + # When the service account file exists, credentials must be built via + # google.oauth2.service_account.Credentials.from_service_account_file. + # google.oauth2.credentials.Credentials does not provide that method. + mocker.patch("pyfcm.baseapi.path.isfile", return_value=True) + mock_from_file = mocker.patch( + "pyfcm.baseapi.service_account.Credentials.from_service_account_file", + return_value="dummy-credentials", + ) + + fcm = FCMNotification( + service_account_file="./service_account.json", + project_id="test", + credentials=None, + ) + fcm._initialize_credentials() + + mock_from_file.assert_called_once() + assert fcm.credentials == "dummy-credentials" + def test_push_service_directly_passed_credentials(push_service): # We should infer the project ID/endpoint from credentials # without the need to explcitily pass it From 8526260f517bfc738646eb877f650e35383b69ba Mon Sep 17 00:00:00 2001 From: Lex Alexander Date: Tue, 18 Aug 2026 12:19:39 -0400 Subject: [PATCH 4/5] Add regression test for credentials passed instead of service account file --- pyfcm/baseapi.py | 32 ++++++++++++++++------ tests/test_fcm.py | 68 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index fab80de..c22a0bf 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -177,14 +177,30 @@ def _initialize_credentials(self): Initialize credentials and FCM endpoint if not already initialized. """ if self.credentials is None: - if not path.isfile(self._service_account_file): - raise InvalidDataError(f"The service account file you passed does not exist at '{self._service_account_file}'. " - "Ensure it does not have any typos and exists." - ) - self.credentials = service_account.Credentials.from_service_account_file( - self._service_account_file, - scopes=["https://www.googleapis.com/auth/firebase.messaging"], - ) + if isinstance(self._service_account_file, dict): + try: + self.credentials = service_account.Credentials.from_service_account_info( + self._service_account_file, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) + except ValueError as e: + raise InvalidDataError(f"Invalid service account file: {e}") + self._service_account_file = None + return None + + is_path = isinstance(self._service_account_file, (str, bytes)) or hasattr(self._service_account_file, "__fspath__") + + invalid_path = not is_path or not path.isfile(self._service_account_file) + + if invalid_path: + raise InvalidDataError("The service account file does not exist or is not a regular file.") + try: + self.credentials = service_account.Credentials.from_service_account_file( + self._service_account_file, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) + except Exception as e: + raise InvalidDataError(f"Invalid service account file: {e}") self._service_account_file = None def _get_access_token(self): diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 396ba52..ede7573 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -1,5 +1,22 @@ +import json +import pdb from pyfcm import FCMNotification, errors +def test_credentials(): + return { + "type": "service_account", + "project_id": "my-project-123456", + "private_key_id": "abc123def4567890abc123def4567890abc123de", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n...REDACTED...\n-----END PRIVATE KEY-----\n", + "client_email": "my-service-account@my-project-123456.iam.gserviceaccount.com", + "client_id": "123456789012345678901", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my-project-123456.iam.gserviceaccount.com", + "universe_domain": "googleapis.com", + } + def test_push_service_without_credentials(): try: @@ -8,17 +25,59 @@ def test_push_service_without_credentials(): except errors.AuthenticationError: pass + def test_push_service_with_incorrect_service_account_file(): try: - fcm = FCMNotification(service_account_file='./foo.json', project_id=None, credentials=None) + fcm = FCMNotification( + service_account_file="./foo.json", project_id=None, credentials=None + ) fcm.notify() - assert False, "Should raise InvalidDataError without correct service account file path" + assert False, ( + "Should raise InvalidDataError without correct service account file path" + ) except errors.InvalidDataError: pass + +def test_push_works_with_dict_credentials(mocker): + credentials = test_credentials() + mock_from_info = mocker.patch( + "pyfcm.baseapi.service_account.Credentials.from_service_account_info", + return_value=credentials + ) + + fcm = FCMNotification( + service_account_file=credentials, + project_id="test", + credentials=None, + ) + fcm._initialize_credentials() + + mock_from_info.assert_called_once() + assert fcm.credentials == credentials + + +def test_push_service_does_not_leak_credentials(): + import pytest + credentials = test_credentials() + with pytest.raises(errors.InvalidDataError) as exc_info: + fcm = FCMNotification( + service_account_file=json.dumps(credentials), + project_id=None, + credentials=None, + ) + fcm.notify() + + error_message = str(exc_info.value) + credentials = test_credentials() + + assert credentials["private_key"] not in error_message + assert credentials["private_key_id"] not in error_message + + def test_push_service_with_valid_service_account_file(mocker): - # When the service account file exists, credentials must be built via - # google.oauth2.service_account.Credentials.from_service_account_file. + # When the service account file exists, test_credentials must be built via + # google.oauth2.service_account.test_credentials.from_service_account_file. # google.oauth2.credentials.Credentials does not provide that method. mocker.patch("pyfcm.baseapi.path.isfile", return_value=True) mock_from_file = mocker.patch( @@ -36,6 +95,7 @@ def test_push_service_with_valid_service_account_file(mocker): mock_from_file.assert_called_once() assert fcm.credentials == "dummy-credentials" + def test_push_service_directly_passed_credentials(push_service): # We should infer the project ID/endpoint from credentials # without the need to explcitily pass it From 91bd8f00c97dc43e0f8fb42999a5874a578ab4bd Mon Sep 17 00:00:00 2001 From: Debian Date: Tue, 18 Aug 2026 16:27:10 +0000 Subject: [PATCH 5/5] Keep credential file validation focused --- pyfcm/baseapi.py | 30 +++++--------------- tests/test_fcm.py | 71 ++++++++++++----------------------------------- 2 files changed, 24 insertions(+), 77 deletions(-) diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index c22a0bf..fb8928c 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -177,30 +177,14 @@ def _initialize_credentials(self): Initialize credentials and FCM endpoint if not already initialized. """ if self.credentials is None: - if isinstance(self._service_account_file, dict): - try: - self.credentials = service_account.Credentials.from_service_account_info( - self._service_account_file, - scopes=["https://www.googleapis.com/auth/firebase.messaging"], - ) - except ValueError as e: - raise InvalidDataError(f"Invalid service account file: {e}") - self._service_account_file = None - return None - - is_path = isinstance(self._service_account_file, (str, bytes)) or hasattr(self._service_account_file, "__fspath__") - - invalid_path = not is_path or not path.isfile(self._service_account_file) - - if invalid_path: - raise InvalidDataError("The service account file does not exist or is not a regular file.") - try: - self.credentials = service_account.Credentials.from_service_account_file( - self._service_account_file, - scopes=["https://www.googleapis.com/auth/firebase.messaging"], + if not path.isfile(self._service_account_file): + raise InvalidDataError( + "The service account file does not exist or is not a regular file." ) - except Exception as e: - raise InvalidDataError(f"Invalid service account file: {e}") + self.credentials = service_account.Credentials.from_service_account_file( + self._service_account_file, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) self._service_account_file = None def _get_access_token(self): diff --git a/tests/test_fcm.py b/tests/test_fcm.py index ede7573..ef0c028 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -1,21 +1,6 @@ -import json -import pdb -from pyfcm import FCMNotification, errors +import pytest -def test_credentials(): - return { - "type": "service_account", - "project_id": "my-project-123456", - "private_key_id": "abc123def4567890abc123def4567890abc123de", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n...REDACTED...\n-----END PRIVATE KEY-----\n", - "client_email": "my-service-account@my-project-123456.iam.gserviceaccount.com", - "client_id": "123456789012345678901", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my-project-123456.iam.gserviceaccount.com", - "universe_domain": "googleapis.com", - } +from pyfcm import FCMNotification, errors def test_push_service_without_credentials(): @@ -26,58 +11,33 @@ def test_push_service_without_credentials(): pass -def test_push_service_with_incorrect_service_account_file(): - try: +def test_push_service_with_incorrect_service_account_file(tmp_path): + missing_file = tmp_path / "missing.json" + with pytest.raises(errors.InvalidDataError): fcm = FCMNotification( - service_account_file="./foo.json", project_id=None, credentials=None + service_account_file=missing_file, project_id=None, credentials=None ) fcm.notify() - assert False, ( - "Should raise InvalidDataError without correct service account file path" - ) - except errors.InvalidDataError: - pass - - -def test_push_works_with_dict_credentials(mocker): - credentials = test_credentials() - mock_from_info = mocker.patch( - "pyfcm.baseapi.service_account.Credentials.from_service_account_info", - return_value=credentials - ) - - fcm = FCMNotification( - service_account_file=credentials, - project_id="test", - credentials=None, - ) - fcm._initialize_credentials() - - mock_from_info.assert_called_once() - assert fcm.credentials == credentials def test_push_service_does_not_leak_credentials(): - import pytest - credentials = test_credentials() + raw_credentials = '{"private_key":"TOP-SECRET-PRIVATE-KEY"}' with pytest.raises(errors.InvalidDataError) as exc_info: fcm = FCMNotification( - service_account_file=json.dumps(credentials), + service_account_file=raw_credentials, project_id=None, credentials=None, ) - fcm.notify() + fcm._initialize_credentials() error_message = str(exc_info.value) - credentials = test_credentials() - - assert credentials["private_key"] not in error_message - assert credentials["private_key_id"] not in error_message + assert raw_credentials not in error_message + assert "TOP-SECRET-PRIVATE-KEY" not in error_message def test_push_service_with_valid_service_account_file(mocker): - # When the service account file exists, test_credentials must be built via - # google.oauth2.service_account.test_credentials.from_service_account_file. + # When the service account file exists, credentials must be built via + # google.oauth2.service_account.Credentials.from_service_account_file. # google.oauth2.credentials.Credentials does not provide that method. mocker.patch("pyfcm.baseapi.path.isfile", return_value=True) mock_from_file = mocker.patch( @@ -92,7 +52,10 @@ def test_push_service_with_valid_service_account_file(mocker): ) fcm._initialize_credentials() - mock_from_file.assert_called_once() + mock_from_file.assert_called_once_with( + "./service_account.json", + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) assert fcm.credentials == "dummy-credentials"