From: Dmitry Torokhov <dmitry.torokhov@gmail.com>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
Subject: [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management
Date: Tue, 10 Nov 2026 22:59:18 -0800 [thread overview]
Message-ID: <20261110-token-command-v1-1-a089145494d3@gmail.com> (raw)
In-Reply-To: <20261110-token-command-v1-0-a089145494d3@gmail.com>
Move OAuth client secrets and token file management out of GmailTarget
into OAuthFileCredentials in google_creds, subclassing
google.auth.credentials.Credentials, and pass credentials directly into
GmailTarget via make_gmail_credentials.
This keeps Google OAuth credential transport separate from Gmail API
delivery and configuration parsing, and prepares for supporting
alternative credential providers.
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
src/korgalore/cli.py | 23 ++---
src/korgalore/gmail_target.py | 212 ++++++++++++++++++--------------------
src/korgalore/google_creds.py | 229 ++++++++++++++++++++++++++++++++++++++++++
tests/test_gmail_target.py | 229 +++++++++++++++++++++---------------------
tests/test_google_creds.py | 154 ++++++++++++++++++++++++++++
5 files changed, 610 insertions(+), 237 deletions(-)
diff --git a/src/korgalore/cli.py b/src/korgalore/cli.py
index 3e3e615..180906d 100644
--- a/src/korgalore/cli.py
+++ b/src/korgalore/cli.py
@@ -54,7 +54,7 @@ from korgalore.digest import (
strip_reply_prefixes,
)
from korgalore.dummy_target import DummyTarget
-from korgalore.gmail_target import GmailTarget
+from korgalore.gmail_target import GmailTarget, make_gmail_credentials
from korgalore.imap_target import ImapTarget
from korgalore.jmap_target import JmapTarget
from korgalore.lei_feed import LeiFeed
@@ -280,8 +280,7 @@ def get_target(ctx: click.Context, identifier: str) -> Any:
if target_type == 'gmail':
service = get_gmail_target(
identifier=identifier,
- credentials_file=details.get('credentials', ''),
- token_file=details.get('token', None),
+ details=details,
interactive=interactive,
)
elif target_type == 'maildir':
@@ -334,19 +333,19 @@ def get_target(ctx: click.Context, identifier: str) -> Any:
def get_gmail_target(
- identifier: str, credentials_file: str, token_file: str | None, interactive: bool = True
+ identifier: str,
+ details: Mapping[str, Any],
+ interactive: bool = True,
) -> GmailTarget:
"""Create a Gmail target service instance."""
- if not credentials_file:
- logger.critical('No credentials file specified for Gmail target: %s', identifier)
- raise click.Abort()
- if not token_file:
- cfgdir = get_xdg_config_dir()
- token_file = str(cfgdir / f'gmail-{identifier}-token.json')
try:
- gt = GmailTarget(
- identifier=identifier, credentials_file=credentials_file, token_file=token_file, interactive=interactive
+ creds = make_gmail_credentials(
+ identifier=identifier,
+ details=details,
+ interactive=interactive,
+ config_dir=get_xdg_config_dir(),
)
+ gt = GmailTarget(identifier=identifier, creds=creds)
except ConfigurationError as fe:
logger.critical('Error: %s', str(fe))
raise click.Abort() from fe
diff --git a/src/korgalore/gmail_target.py b/src/korgalore/gmail_target.py
index c88c3d9..3fd9f8b 100644
--- a/src/korgalore/gmail_target.py
+++ b/src/korgalore/gmail_target.py
@@ -1,22 +1,21 @@
+import base64
import logging
import os
+from collections.abc import Mapping
from pathlib import Path
from typing import Any
-from google.auth.exceptions import RefreshError
-from google.auth.external_account_authorized_user import Credentials as ExternalAccountCredentials
+from google.auth.credentials import Credentials as BaseCredentials
from google.auth.transport.requests import Request
-from google.oauth2.credentials import Credentials
-from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore
-from googleapiclient.discovery import build # type: ignore
-from googleapiclient.errors import HttpError # type: ignore
+from googleapiclient.discovery import Resource, build # type: ignore[import-untyped]
+from googleapiclient.errors import HttpError # type: ignore[import-untyped]
from korgalore import AuthenticationError, ConfigurationError, RemoteError
+from korgalore.google_creds import OAuthFileCredentials
from korgalore.message import RawMessage
logger = logging.getLogger('korgalore')
-
# If modifying these scopes, delete the file token.json.
# We need scopes for reading and inserting new emails, but not
# modifying existing ones.
@@ -25,105 +24,52 @@ SCOPES = [
'https://www.googleapis.com/auth/gmail.insert',
]
-# InstalledAppFlow.run_local_server() returns either flavour depending on what
-# the client_secrets file describes. Both expose the valid/expired/refresh_token
-# attributes and the to_json() and refresh() methods used below, so the code
-# does not care which one it gets -- but the declared type has to allow both.
-GmailCredentials = Credentials | ExternalAccountCredentials
-
class GmailTarget:
"""Target class for delivering email messages to Gmail via the API."""
DEFAULT_LABELS: list[str] = ['INBOX', 'UNREAD']
- def __init__(self, identifier: str, credentials_file: str, token_file: str, interactive: bool = True) -> None:
+ def __init__(self, identifier: str, creds: BaseCredentials) -> None:
"""Initialize a GmailTarget instance.
Args:
identifier: Unique identifier for this Gmail target.
- credentials_file: Path to the Google OAuth credentials JSON file.
- token_file: Path to store/load the OAuth token.
- interactive: If True, run OAuth flow interactively when needed.
- If False, raise AuthenticationError instead (for GUI mode).
-
- Raises:
- ConfigurationError: If credentials file is not found.
- AuthenticationError: If token is expired/revoked and re-auth is needed,
- or if interactive=False and no token exists.
+ creds: Google API credentials instance.
"""
self.identifier = identifier
- self.creds: GmailCredentials | None = None
- self.service: Any | None = None
+ self.creds = creds
+ self.service: Resource | None = None
self._label_map: dict[str, str] | None = None
- # Store expanded paths for potential re-authentication
- self._credentials_file = Path(os.path.expandvars(credentials_file)).expanduser()
- self._token_file = Path(os.path.expandvars(token_file)).expanduser()
- self._needs_auth = False
- self._interactive = interactive
- self._load_credentials()
-
- def _load_credentials(self) -> None:
- """Load or refresh OAuth credentials for Gmail API access.
- Attempts to load existing credentials from token_file. If not present
- or expired, initiates OAuth flow using credentials_file.
-
- Raises:
- ConfigurationError: If credentials file is not found.
- AuthenticationError: If token is expired/revoked and re-auth is needed.
- """
- # The file token.json stores the user's access and refresh tokens
- if self._token_file.exists():
- self.creds = Credentials.from_authorized_user_file(str(self._token_file), SCOPES) # type: ignore
-
- # If there are no (valid) credentials available, let the user log in
- if not self.creds or not self.creds.valid:
- if self.creds and self.creds.expired and self.creds.refresh_token:
- try:
- self.creds.refresh(Request()) # type: ignore
- except RefreshError as e:
- logger.warning('Gmail token for %s has expired or been revoked.', self.identifier)
- invalid_token_file = self._token_file.with_name(f'{self._token_file.name}.invalid')
- invalid_token_file.unlink(missing_ok=True)
- self._token_file.rename(invalid_token_file)
- self._needs_auth = True
- if not self._interactive:
- # In non-interactive mode, just return - caller will check needs_auth
- return
- raise AuthenticationError(
- f"Gmail token for '{self.identifier}' is invalid. Please re-authenticate.",
- target_id=self.identifier,
- target_type='gmail',
- ) from e
- elif self._credentials_file.exists():
- if not self._interactive:
- # In non-interactive mode (GUI), don't run OAuth flow
- # Just mark as needing auth and return - caller will check needs_auth
- self._needs_auth = True
- return
- logger.critical('Log in to Gmail account for %s', self.identifier)
-
- flow = InstalledAppFlow.from_client_secrets_file(str(self._credentials_file), SCOPES)
- self.creds = flow.run_local_server(port=0)
- else:
- raise ConfigurationError(
- f'{self._credentials_file} not found. Please download it from Google Cloud Console.'
- )
-
- # Save the credentials for the next run
- self._token_file.write_text(self.creds.to_json())
-
- self._needs_auth = False
+ @property
+ def needs_auth(self) -> bool:
+ """Check if this target needs re-authentication."""
+ return bool(getattr(self.creds, 'needs_auth', False))
def connect(self) -> None:
"""Establish connection to the Gmail API service.
Creates the Gmail API service object if not already connected.
+
+ Raises:
+ ConfigurationError: If credentials file is not found during refresh.
+ AuthenticationError: If OAuth token refresh or authentication fails.
"""
- if self.service is None:
- logger.debug('Connecting to Gmail service for %s', self.identifier)
- self.service = build('gmail', 'v1', credentials=self.creds, cache_discovery=False)
+ if self.service is not None:
+ return
+
+ if not self.creds.valid:
+ self.creds.refresh(Request()) # type: ignore[no-untyped-call]
+
+ logger.debug('Connecting to Gmail service for %s', self.identifier)
+ self.service = build('gmail', 'v1', credentials=self.creds, cache_discovery=False)
+ try:
+ self._label_map = {label['name']: label['id'] for label in self.list_labels()}
+ except Exception:
+ self.service = None
+ self._label_map = None
+ raise
def _api(self) -> Any:
"""Return the connected Gmail API service, connecting first if needed.
@@ -163,8 +109,8 @@ class GmailTarget:
"""
# Translate label names to their corresponding IDs
if self._label_map is None:
- # Get all labels from Gmail
- self._label_map = {label['name']: label['id'] for label in self.list_labels()}
+ self.connect()
+ assert self._label_map is not None
translated: list[str] = []
for label in labels:
label_id = self._label_map.get(label, None)
@@ -197,8 +143,6 @@ class GmailTarget:
RemoteError: If the Gmail API call fails.
"""
try:
- import base64
-
msg = RawMessage(raw_message)
encoded_message = base64.urlsafe_b64encode(msg.as_bytes(feed_name, delivery_name)).decode()
message_body: dict[str, Any] = {'raw': encoded_message}
@@ -215,33 +159,77 @@ class GmailTarget:
except HttpError as error:
raise RemoteError(f'An error occurred: {error}') from error
- @property
- def needs_auth(self) -> bool:
- """Check if this target needs re-authentication."""
- return self._needs_auth
-
def reauthenticate(self) -> None:
"""Perform OAuth re-authentication flow.
- Opens a browser window for the user to log in and authorize the app.
- Updates credentials upon successful authentication.
+ Delegates interactive re-authentication to the underlying credentials
+ provider and clears cached API state so the next operation reconnects
+ with the updated credentials.
Raises:
ConfigurationError: If credentials file is not found.
+ AuthenticationError: If interactive re-authentication is unsupported
+ or the OAuth flow fails.
"""
- if not self._credentials_file.exists():
- raise ConfigurationError(
- f'{self._credentials_file} not found. Please download it from Google Cloud Console.'
+ reauth = getattr(self.creds, 'reauthenticate', None)
+ if not callable(reauth):
+ raise AuthenticationError(
+ f"Target '{self.identifier}' does not support interactive reauthentication.",
+ target_id=self.identifier,
+ target_type='gmail',
)
-
- logger.info('Starting re-authentication for Gmail account %s', self.identifier)
-
- flow = InstalledAppFlow.from_client_secrets_file(str(self._credentials_file), SCOPES)
- self.creds = flow.run_local_server(port=0)
-
- # Save the credentials
- self._token_file.write_text(self.creds.to_json())
-
- self._needs_auth = False
- self.service = None # Reset service to force reconnect with new creds
- logger.info('Re-authentication successful for %s', self.identifier)
+ reauth()
+ self.service = None
+ self._label_map = None
+
+
+def make_gmail_credentials(
+ identifier: str,
+ details: Mapping[str, Any],
+ interactive: bool = True,
+ config_dir: Path | str | None = None,
+) -> BaseCredentials:
+ """Create credentials for a Gmail target from configuration.
+
+ Args:
+ identifier: Unique identifier for the Gmail target.
+ details: Target configuration mapping from ``korgalore.toml``.
+ interactive: Whether interactive OAuth browser flows are permitted.
+ config_dir: Optional base configuration directory for default token paths.
+
+ Returns:
+ Configured credentials instance compatible with ``GmailTarget``.
+
+ Raises:
+ ConfigurationError: If required configuration keys are missing or have
+ invalid types.
+ """
+ if 'credentials' not in details:
+ raise ConfigurationError(f"No credentials file specified for Gmail target '{identifier}'.")
+
+ creds_raw = details['credentials']
+ if not isinstance(creds_raw, str):
+ raise ConfigurationError(f"Gmail target '{identifier}': 'credentials' must be a string.")
+
+ token_raw = details.get('token')
+ if token_raw is not None and not isinstance(token_raw, str):
+ raise ConfigurationError(f"Gmail target '{identifier}': 'token' must be a string.")
+
+ if not token_raw:
+ base_dir = (
+ Path(config_dir)
+ if config_dir is not None
+ else Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'korgalore'
+ )
+ token_file: str | Path = base_dir / f'gmail-{identifier}-token.json'
+ else:
+ token_file = token_raw
+
+ return OAuthFileCredentials(
+ identifier=identifier,
+ credentials_file=creds_raw,
+ token_file=token_file,
+ scopes=SCOPES,
+ interactive=interactive,
+ target_type='gmail',
+ )
diff --git a/src/korgalore/google_creds.py b/src/korgalore/google_creds.py
new file mode 100644
index 0000000..f1963bb
--- /dev/null
+++ b/src/korgalore/google_creds.py
@@ -0,0 +1,229 @@
+"""Google credential providers for korgalore."""
+
+import logging
+import os
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+from google.auth.credentials import Credentials as BaseCredentials
+from google.auth.exceptions import RefreshError
+from google.auth.external_account_authorized_user import Credentials as ExternalAccountCredentials
+from google.auth.transport.requests import Request
+from google.oauth2.credentials import Credentials
+from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore[import-untyped]
+
+from korgalore import AuthenticationError, ConfigurationError
+
+logger = logging.getLogger('korgalore')
+
+# InstalledAppFlow.run_local_server() returns either flavour depending on what
+# the client_secrets file describes. Both expose the valid/expired/refresh_token
+# attributes and the to_json() and refresh() methods used below, so the code
+# does not care which one it gets -- but the declared type has to allow both.
+OAuthCredentials = Credentials | ExternalAccountCredentials
+
+
+class OAuthFileCredentials(BaseCredentials):
+ """Credentials backed by Google OAuth client secrets and a token file."""
+
+ def __init__(
+ self,
+ identifier: str,
+ credentials_file: str,
+ token_file: str | Path,
+ scopes: Sequence[str],
+ interactive: bool = True,
+ target_type: str = 'gmail',
+ ) -> None:
+ """Initialize file-backed OAuth credentials for a Google target.
+
+ Args:
+ identifier: Unique identifier for this target.
+ credentials_file: Path to the Google OAuth credentials JSON file.
+ token_file: Path to store/load the OAuth token.
+ scopes: OAuth scopes to request when running interactive auth.
+ interactive: If True, run OAuth flow interactively when needed.
+ If False, record ``needs_auth`` instead (for GUI mode).
+ target_type: Target type string reported in AuthenticationError.
+
+ Raises:
+ ConfigurationError: If credentials file path is empty or missing.
+ AuthenticationError: If token is expired/revoked and re-auth is needed,
+ or if an interactive OAuth flow fails.
+ """
+ self._creds: OAuthCredentials | None = None
+ super().__init__() # type: ignore[no-untyped-call]
+ self.identifier = identifier
+ self._interactive = interactive
+ self._scopes = list(scopes)
+ self._target_type = target_type
+ self.needs_auth = False
+
+ if not credentials_file:
+ raise ConfigurationError(f"No credentials file specified for target '{identifier}'.")
+ # Store expanded paths for potential re-authentication
+ self._credentials_file = Path(os.path.expandvars(credentials_file)).expanduser()
+ self._token_file = Path(os.path.expandvars(os.fspath(token_file))).expanduser()
+
+ self._load_credentials()
+
+ def _load_credentials(self) -> None:
+ """Load or refresh OAuth credentials for API access.
+
+ Attempts to load existing credentials from ``self._token_file``. If
+ not present or expired, refreshes with the stored refresh token or
+ initiates the OAuth flow using ``self._credentials_file``.
+
+ Raises:
+ ConfigurationError: If credentials file is not found.
+ AuthenticationError: If token is expired/revoked and re-auth is needed.
+ """
+ # The file token.json stores the user's access and refresh tokens
+ if self._token_file.exists():
+ try:
+ self._creds = Credentials.from_authorized_user_file(str(self._token_file)) # type: ignore[no-untyped-call]
+ except Exception as e:
+ logger.warning('Failed to load OAuth token for %s from %s: %s', self.identifier, self._token_file, e)
+ self._creds = None
+
+ # If there are no (valid) credentials available, let the user log in
+ if not self._creds or not self._creds.valid:
+ if self._creds and self._creds.expired and self._creds.refresh_token:
+ try:
+ self._creds.refresh(Request()) # type: ignore[no-untyped-call]
+ self._save_token()
+ except RefreshError as e:
+ logger.warning('OAuth token for target %s has expired or been revoked.', self.identifier)
+ self._quarantine_token()
+ self.needs_auth = True
+ if not self._interactive:
+ # In non-interactive mode, just return - caller will check needs_auth
+ return
+ raise AuthenticationError(
+ f"OAuth token for '{self.identifier}' is invalid. Please re-authenticate.",
+ target_id=self.identifier,
+ target_type=self._target_type,
+ ) from e
+ elif not self._credentials_file.exists():
+ raise ConfigurationError(
+ f"Credentials file not found for target '{self.identifier}': {self._credentials_file}"
+ )
+ elif not self._interactive:
+ # In non-interactive mode (GUI), don't run OAuth flow
+ # Just mark as needing auth and return - caller will check needs_auth
+ self.needs_auth = True
+ return
+ else:
+ self.reauthenticate()
+ return
+
+ self.needs_auth = False
+
+ @property
+ def token(self) -> str | None:
+ """Current OAuth2 access token, or None if not loaded."""
+ if self._creds and self._creds.token is not None:
+ return str(self._creds.token)
+ return None
+
+ @token.setter
+ def token(self, value: str | None) -> None:
+ if self._creds:
+ self._creds.token = value
+
+ @property
+ def expiry(self) -> Any:
+ """Expiration timestamp of the current access token, or None."""
+ return self._creds.expiry if self._creds else None
+
+ @expiry.setter
+ def expiry(self, value: Any) -> None:
+ if self._creds:
+ self._creds.expiry = value
+
+ @property
+ def valid(self) -> bool:
+ """Whether loaded OAuth2 credentials are present and currently valid."""
+ return bool(self._creds and self._creds.valid)
+
+ def _save_token(self) -> None:
+ """Persist current OAuth credentials to the configured token file."""
+ if self._creds is None:
+ return
+ # Save the credentials for the next run
+ self._token_file.parent.mkdir(parents=True, exist_ok=True)
+ self._token_file.write_text(self._creds.to_json()) # type: ignore[no-untyped-call]
+ self._token_file.chmod(0o600)
+
+ def _quarantine_token(self) -> None:
+ """Rename a revoked or invalid token file aside to ``.invalid``."""
+ if self._token_file.exists():
+ invalid_token_file = self._token_file.with_name(f'{self._token_file.name}.invalid')
+ invalid_token_file.unlink(missing_ok=True)
+ self._token_file.rename(invalid_token_file)
+
+ def refresh(self, request: Any = None) -> None:
+ """Refresh the access token using the stored refresh token.
+
+ Args:
+ request: Optional Google auth transport request object.
+
+ Raises:
+ ConfigurationError: If credentials file is missing during
+ interactive re-authentication.
+ AuthenticationError: If refresh fails or no token is loaded in
+ non-interactive mode.
+ """
+ if self._creds is None:
+ if self._interactive:
+ self.reauthenticate()
+ return
+ self.needs_auth = True
+ raise AuthenticationError(
+ f"Target '{self.identifier}' requires authentication.",
+ target_id=self.identifier,
+ target_type=self._target_type,
+ )
+ req = request or Request()
+ try:
+ self._creds.refresh(req) # type: ignore[no-untyped-call]
+ except RefreshError as e:
+ logger.warning('OAuth token for target %s has expired or been revoked.', self.identifier)
+ self._quarantine_token()
+ self.needs_auth = True
+ raise AuthenticationError(
+ f"Failed to refresh credentials for target '{self.identifier}': {e}",
+ target_id=self.identifier,
+ target_type=self._target_type,
+ ) from e
+ self._save_token()
+ self.needs_auth = False
+
+ def reauthenticate(self) -> None:
+ """Perform OAuth re-authentication flow.
+
+ Opens a browser window for the user to log in and authorize the app.
+ Updates credentials upon successful authentication.
+
+ Raises:
+ ConfigurationError: If credentials file is not found.
+ AuthenticationError: If the local OAuth authorization flow fails.
+ """
+ if not self._credentials_file.exists():
+ raise ConfigurationError(
+ f"Credentials file not found for target '{self.identifier}': {self._credentials_file}"
+ )
+ logger.critical('Log in to account for %s', self.identifier)
+ try:
+ flow = InstalledAppFlow.from_client_secrets_file(str(self._credentials_file), self._scopes)
+ self._creds = flow.run_local_server(port=0)
+ except Exception as e:
+ self.needs_auth = True
+ raise AuthenticationError(
+ f"OAuth flow failed for target '{self.identifier}': {e}",
+ target_id=self.identifier,
+ target_type=self._target_type,
+ ) from e
+ self._save_token()
+ self.needs_auth = False
diff --git a/tests/test_gmail_target.py b/tests/test_gmail_target.py
index c3a3ed4..9e27f1c 100644
--- a/tests/test_gmail_target.py
+++ b/tests/test_gmail_target.py
@@ -5,28 +5,25 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
-from google.auth.exceptions import RefreshError
+from google.auth.credentials import Credentials as BaseCredentials
from googleapiclient.errors import HttpError # type: ignore[import-untyped]
-from korgalore import ConfigurationError, RemoteError
-from korgalore.gmail_target import SCOPES, GmailTarget
+from korgalore import AuthenticationError, ConfigurationError, RemoteError
+from korgalore.gmail_target import SCOPES, GmailTarget, make_gmail_credentials
+from korgalore.google_creds import OAuthFileCredentials
-def make_target(tmp_path: Path) -> tuple[GmailTarget, MagicMock]:
- """A GmailTarget with valid stored credentials; returns it with those credentials."""
- token = tmp_path / 'token.json'
- token.write_text('{}')
- with patch('korgalore.gmail_target.Credentials') as mock_creds_class:
- mock_creds = MagicMock()
- mock_creds.valid = True
- mock_creds_class.from_authorized_user_file.return_value = mock_creds
- return GmailTarget('test', str(tmp_path / 'creds.json'), str(token)), mock_creds
+def make_target() -> tuple[GmailTarget, MagicMock]:
+ """A GmailTarget with valid mock credentials."""
+ mock_creds = MagicMock(spec=BaseCredentials)
+ mock_creds.valid = True
+ return GmailTarget('test', creds=mock_creds), mock_creds
@pytest.fixture
-def gmail(tmp_path: Path) -> tuple[GmailTarget, MagicMock]:
+def gmail() -> tuple[GmailTarget, MagicMock]:
"""A target with a mocked API service; the label map is not loaded yet."""
- target, _ = make_target(tmp_path)
+ target, _ = make_target()
service = MagicMock()
target.service = service
return target, service
@@ -52,123 +49,85 @@ def import_body(service: MagicMock) -> dict[str, object]:
class TestGmailTargetInit:
- """Tests for GmailTarget initialization."""
+ """Tests for GmailTarget initialization and credential delegation."""
- @patch('korgalore.gmail_target.Credentials')
- def test_loads_existing_valid_token(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
- """Loads credentials from the stored token; the service waits for connect()."""
- token = tmp_path / 'token.json'
- token.write_text('{}')
- mock_creds = MagicMock()
- mock_creds.valid = True
- mock_credentials.from_authorized_user_file.return_value = mock_creds
-
- target = GmailTarget('test', str(tmp_path / 'creds.json'), str(token))
+ def test_init_with_credentials(self) -> None:
+ """Stores identifier and credentials without building API service yet."""
+ target, mock_creds = make_target()
assert target.identifier == 'test'
assert target.creds is mock_creds
assert target.service is None
- mock_credentials.from_authorized_user_file.assert_called_once_with(str(token), SCOPES)
-
- @patch('korgalore.gmail_target.Credentials')
- @patch('korgalore.gmail_target.Request')
- def test_refreshes_expired_token(
- self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
- ) -> None:
- """Refreshes expired credentials with refresh token and saves the result."""
- token = tmp_path / 'token.json'
- token.write_text('{"token": "stale"}')
- mock_creds = MagicMock()
- mock_creds.valid = False
- mock_creds.expired = True
- mock_creds.refresh_token = 'refresh_token_value'
- mock_creds.to_json.return_value = '{"token": "refreshed"}'
- mock_credentials.from_authorized_user_file.return_value = mock_creds
-
- GmailTarget('test', str(tmp_path / 'creds.json'), str(token))
-
- mock_creds.refresh.assert_called_once()
- assert token.read_text() == '{"token": "refreshed"}'
- @patch('korgalore.gmail_target.Credentials')
- @patch('korgalore.gmail_target.Request')
- def test_revoked_token_is_set_aside(
- self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
- ) -> None:
- """A token that fails to refresh is renamed out of the way and auth is flagged."""
- token = tmp_path / 'token.json'
- token.write_text('{"token": "revoked"}')
- invalid = tmp_path / 'token.json.invalid'
- invalid.write_text('{"token": "older"}')
- mock_creds = MagicMock()
- mock_creds.valid = False
- mock_creds.expired = True
- mock_creds.refresh_token = 'refresh_token_value'
- mock_creds.refresh.side_effect = RefreshError
- mock_credentials.from_authorized_user_file.return_value = mock_creds
-
- target = GmailTarget('test', str(tmp_path / 'creds.json'), str(token), interactive=False)
-
- assert target.needs_auth
- assert not token.exists()
- assert invalid.read_text() == '{"token": "revoked"}'
-
- @patch('korgalore.gmail_target.InstalledAppFlow')
- def test_runs_oauth_flow_and_saves_token_when_no_token(self, mock_flow_class: MagicMock, tmp_path: Path) -> None:
- """Runs the OAuth flow when no token exists, then saves the new token."""
- creds = tmp_path / 'creds.json'
- creds.write_text('{}')
- token = tmp_path / 'token.json'
- mock_creds = MagicMock()
- mock_creds.to_json.return_value = '{"access_token": "new_token"}'
- mock_flow = MagicMock()
- mock_flow.run_local_server.return_value = mock_creds
- mock_flow_class.from_client_secrets_file.return_value = mock_flow
-
- target = GmailTarget('test', str(creds), str(token))
-
- mock_flow_class.from_client_secrets_file.assert_called_once_with(str(creds), SCOPES)
- mock_flow.run_local_server.assert_called_once_with(port=0)
- assert target.creds is mock_creds
- assert token.read_text() == '{"access_token": "new_token"}'
+ def test_reauthenticate_delegates_and_resets_service(self, gmail_labels: tuple[GmailTarget, MagicMock]) -> None:
+ """Delegates reauthenticate() to creds and clears cached service state."""
+ target, _ = gmail_labels
+ reauth_mock = MagicMock()
+ setattr(target.creds, 'reauthenticate', reauth_mock)
- def test_missing_credentials_file_raises(self, tmp_path: Path) -> None:
- """Missing credentials file raises ConfigurationError."""
- # Neither the token file nor the credentials file exists
- with pytest.raises(ConfigurationError) as exc_info:
- GmailTarget('test', str(tmp_path / 'creds.json'), str(tmp_path / 'token.json'))
- assert 'not found' in str(exc_info.value)
- assert 'creds.json' in str(exc_info.value)
+ target.reauthenticate()
- @patch('korgalore.gmail_target.Credentials')
- def test_expands_user_paths(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
- """Tilde and env vars in paths are expanded."""
- token = tmp_path / 'token.json'
- token.write_text('{}')
- mock_credentials.from_authorized_user_file.return_value = MagicMock(valid=True)
+ reauth_mock.assert_called_once_with()
+ assert target.service is None
+ assert target._label_map is None
- with patch.dict('os.environ', {'HOME': str(tmp_path)}):
- GmailTarget('test', '~/creds.json', '$HOME/token.json')
+ def test_reauthenticate_unsupported_raises(self) -> None:
+ """Raises AuthenticationError when credentials do not support reauth."""
+ target, _ = make_target()
- mock_credentials.from_authorized_user_file.assert_called_once_with(str(token), SCOPES)
+ with pytest.raises(AuthenticationError, match='does not support interactive reauthentication'):
+ target.reauthenticate()
class TestGmailTargetConnect:
"""Tests for GmailTarget connect method."""
@patch('korgalore.gmail_target.build')
- def test_connect_builds_service_once(self, mock_build: MagicMock, tmp_path: Path) -> None:
+ def test_connect_builds_service_once(self, mock_build: MagicMock) -> None:
"""Connect builds the Gmail API service, and repeated calls don't rebuild it."""
- target, mock_creds = make_target(tmp_path)
+ target, mock_creds = make_target()
mock_service = MagicMock()
+ mock_service.users().labels().list().execute.return_value = {'labels': [{'id': 'INBOX', 'name': 'INBOX'}]}
mock_build.return_value = mock_service
target.connect()
+ mock_build.assert_called_once_with('gmail', 'v1', credentials=mock_creds, cache_discovery=False)
+ assert target.service is mock_service
+ assert target._label_map == {'INBOX': 'INBOX'}
+
target.connect()
target.connect()
+ mock_build.assert_called_once()
- mock_build.assert_called_once_with('gmail', 'v1', credentials=mock_creds, cache_discovery=False)
- assert target.service is mock_service
+ @patch('korgalore.gmail_target.build')
+ def test_connect_failure_resets_state(self, mock_build: MagicMock) -> None:
+ """Label probe failure resets service and label map to None."""
+ target, _ = make_target()
+ mock_service = MagicMock()
+ mock_service.users().labels().list().execute.side_effect = http_error(403, b'Forbidden')
+ mock_build.return_value = mock_service
+
+ with pytest.raises(RemoteError):
+ target.connect()
+
+ assert target.service is None
+ assert target._label_map is None
+
+ @patch('korgalore.gmail_target.build')
+ @patch('korgalore.gmail_target.Request')
+ def test_connect_refreshes_invalid_credentials(self, mock_request: MagicMock, mock_build: MagicMock) -> None:
+ """Refreshes invalid credentials before building the API service."""
+ target, mock_creds = make_target()
+ mock_creds.valid = False
+ mock_service = MagicMock()
+ mock_service.users().labels().list().execute.return_value = {'labels': []}
+ mock_build.return_value = mock_service
+
+ target.connect()
+
+ mock_creds.refresh.assert_called_once_with(mock_request.return_value)
+ mock_build.assert_called_once()
+ assert target._label_map == {}
class TestGmailTargetListLabels:
@@ -215,9 +174,12 @@ class TestGmailTargetListLabels:
class TestGmailTargetTranslateLabels:
"""Tests for GmailTarget translate_labels method."""
- def test_translate_labels(self, gmail: tuple[GmailTarget, MagicMock]) -> None:
+ @patch('korgalore.gmail_target.build')
+ def test_translate_labels(self, mock_build: MagicMock) -> None:
"""Label names become IDs, in order; the label map is fetched only once."""
- target, service = gmail
+ target, _ = make_target()
+ service = MagicMock()
+ mock_build.return_value = service
service.users().labels().list().execute.return_value = {
'labels': [
{'id': 'INBOX', 'name': 'INBOX'},
@@ -231,9 +193,13 @@ class TestGmailTargetTranslateLabels:
assert target.translate_labels(['MyLabel']) == ['Label_123']
assert service.users().labels().list().execute.call_count == 1
+ assert mock_build.call_count == 1
- def test_translate_unknown_label_raises(self, gmail: tuple[GmailTarget, MagicMock]) -> None:
- target, service = gmail
+ @patch('korgalore.gmail_target.build')
+ def test_translate_unknown_label_raises(self, mock_build: MagicMock) -> None:
+ target, _ = make_target()
+ service = MagicMock()
+ mock_build.return_value = service
service.users().labels().list().execute.return_value = {'labels': [{'id': 'INBOX', 'name': 'INBOX'}]}
with pytest.raises(ConfigurationError) as exc_info:
@@ -241,8 +207,8 @@ class TestGmailTargetTranslateLabels:
assert 'not found' in str(exc_info.value)
assert 'NonExistent' in str(exc_info.value)
- def test_label_names_are_case_sensitive(self, gmail: tuple[GmailTarget, MagicMock]) -> None:
- target, _ = gmail
+ def test_label_names_are_case_sensitive(self) -> None:
+ target, _ = make_target()
target._label_map = {'INBOX': 'INBOX', 'inbox': 'inbox_lower'}
assert target.translate_labels(['INBOX']) == ['INBOX']
@@ -329,3 +295,40 @@ class TestGmailTargetImportMessage:
results = [target.import_message(f'Message {i}'.encode(), ['INBOX']) for i in range(5)]
assert [r['id'] for r in results] == ['msg0', 'msg1', 'msg2', 'msg3', 'msg4']
+
+
+class TestMakeGmailCredentials:
+ """Tests for make_gmail_credentials configuration factory."""
+
+ def test_no_credentials_raises(self) -> None:
+ """Omitting credentials raises ConfigurationError."""
+ with pytest.raises(ConfigurationError, match='No credentials file specified'):
+ make_gmail_credentials('test', {})
+
+ @pytest.mark.parametrize(
+ ('details', 'expected_match'),
+ [
+ ({'credentials': 123}, "'credentials' must be a string"),
+ ({'credentials': '/tmp/creds.json', 'token': 456}, "'token' must be a string"),
+ ],
+ ids=['non-str-credentials', 'non-str-token'],
+ )
+ def test_invalid_option_types_raise(self, details: dict[str, object], expected_match: str) -> None:
+ """Non-string configuration values raise ConfigurationError."""
+ with pytest.raises(ConfigurationError, match=expected_match):
+ make_gmail_credentials('test', details)
+
+ @patch('korgalore.google_creds.Credentials')
+ def test_default_token_file_uses_xdg_config_dir(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
+ """When token is omitted, defaults to gmail-{identifier}-token.json in XDG config dir."""
+ default_token = tmp_path / 'korgalore' / 'gmail-personal-token.json'
+ default_token.parent.mkdir(parents=True)
+ default_token.write_text('{}')
+ mock_credentials.from_authorized_user_file.return_value = MagicMock(valid=True)
+
+ with patch.dict('os.environ', {'XDG_CONFIG_HOME': str(tmp_path)}):
+ creds = make_gmail_credentials('personal', {'credentials': str(tmp_path / 'creds.json')})
+
+ mock_credentials.from_authorized_user_file.assert_called_once_with(str(default_token))
+ assert isinstance(creds, OAuthFileCredentials)
+ assert creds._scopes == SCOPES
diff --git a/tests/test_google_creds.py b/tests/test_google_creds.py
new file mode 100644
index 0000000..4ac0a3d
--- /dev/null
+++ b/tests/test_google_creds.py
@@ -0,0 +1,154 @@
+"""Tests for Google OAuth2 credential providers."""
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from google.auth.exceptions import RefreshError
+
+from korgalore import AuthenticationError, ConfigurationError
+from korgalore.google_creds import OAuthFileCredentials
+
+TEST_SCOPES = ['https://example.invalid/scope.a', 'https://example.invalid/scope.b']
+
+
+class TestOAuthFileCredentials:
+ """Tests for OAuthFileCredentials initialization, refresh, and reauth."""
+
+ @patch('korgalore.google_creds.Credentials')
+ def test_loads_existing_valid_token(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
+ """Loads credentials from the stored token when already valid."""
+ token = tmp_path / 'token.json'
+ token.write_text('{}')
+ mock_creds = MagicMock()
+ mock_creds.valid = True
+ mock_creds.token = 'access-tok'
+ mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+ provider = OAuthFileCredentials('test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES)
+
+ assert provider.valid
+ assert provider.token == 'access-tok'
+ assert not provider.needs_auth
+ mock_credentials.from_authorized_user_file.assert_called_once_with(str(token))
+
+ @patch('korgalore.google_creds.Credentials')
+ @patch('korgalore.google_creds.Request')
+ def test_refreshes_expired_token(
+ self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
+ ) -> None:
+ """Refreshes expired credentials with refresh token and saves the result."""
+ token = tmp_path / 'token.json'
+ token.write_text('{"token": "stale"}')
+ mock_creds = MagicMock()
+ mock_creds.valid = False
+ mock_creds.expired = True
+ mock_creds.refresh_token = 'refresh_token_value'
+ mock_creds.to_json.return_value = '{"token": "refreshed"}'
+ mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+ OAuthFileCredentials('test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES)
+
+ mock_creds.refresh.assert_called_once()
+ assert token.read_text() == '{"token": "refreshed"}'
+ assert (token.stat().st_mode & 0o777) == 0o600
+
+ @patch('korgalore.google_creds.Credentials')
+ @patch('korgalore.google_creds.Request')
+ def test_revoked_token_is_set_aside(
+ self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
+ ) -> None:
+ """A token that fails to refresh is renamed aside and needs_auth is flagged."""
+ token = tmp_path / 'token.json'
+ token.write_text('{"token": "revoked"}')
+ invalid = tmp_path / 'token.json.invalid'
+ invalid.write_text('{"token": "older"}')
+ mock_creds = MagicMock()
+ mock_creds.valid = False
+ mock_creds.expired = True
+ mock_creds.refresh_token = 'refresh_token_value'
+ mock_creds.refresh.side_effect = RefreshError
+ mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+ provider = OAuthFileCredentials(
+ 'test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES, interactive=False
+ )
+
+ assert provider.needs_auth
+ assert not token.exists()
+ assert invalid.read_text() == '{"token": "revoked"}'
+
+ @patch('korgalore.google_creds.Credentials')
+ @patch('korgalore.google_creds.Request')
+ def test_revoked_token_interactive_raises_authentication_error(
+ self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
+ ) -> None:
+ """Interactive load raises AuthenticationError when token refresh is revoked."""
+ token = tmp_path / 'token.json'
+ token.write_text('{"token": "revoked"}')
+ mock_creds = MagicMock()
+ mock_creds.valid = False
+ mock_creds.expired = True
+ mock_creds.refresh_token = 'refresh_token_value'
+ mock_creds.refresh.side_effect = RefreshError
+ mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+ with pytest.raises(AuthenticationError, match='invalid'):
+ OAuthFileCredentials('test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES, interactive=True)
+
+ assert not token.exists()
+ assert token.with_name('token.json.invalid').exists()
+
+ @patch('korgalore.google_creds.InstalledAppFlow')
+ def test_runs_oauth_flow_and_saves_token_when_no_token(self, mock_flow_class: MagicMock, tmp_path: Path) -> None:
+ """Runs the OAuth flow interactively, saves the returned credentials, and marks auth done."""
+ creds = tmp_path / 'creds.json'
+ creds.write_text('{}')
+ token = tmp_path / 'token.json'
+ mock_creds = MagicMock()
+ mock_creds.valid = True
+ mock_creds.to_json.return_value = '{"token": "fresh"}'
+ mock_flow = MagicMock()
+ mock_flow.run_local_server.return_value = mock_creds
+ mock_flow_class.from_client_secrets_file.return_value = mock_flow
+
+ provider = OAuthFileCredentials('test', str(creds), token, scopes=TEST_SCOPES)
+
+ mock_flow_class.from_client_secrets_file.assert_called_once_with(str(creds), TEST_SCOPES)
+ mock_flow.run_local_server.assert_called_once_with(port=0)
+ assert provider.valid
+ assert not provider.needs_auth
+ assert token.read_text() == '{"token": "fresh"}'
+ assert (token.stat().st_mode & 0o777) == 0o600
+
+ def test_missing_credentials_file_raises(self, tmp_path: Path) -> None:
+ """Missing credentials file raises ConfigurationError."""
+ with pytest.raises(ConfigurationError) as exc_info:
+ OAuthFileCredentials('test', str(tmp_path / 'creds.json'), tmp_path / 'token.json', scopes=TEST_SCOPES)
+ assert 'not found' in str(exc_info.value)
+ assert 'creds.json' in str(exc_info.value)
+
+ @patch('korgalore.google_creds.Credentials')
+ def test_expands_user_paths(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
+ """Tilde and environment variables in paths are expanded."""
+ token = tmp_path / 'token.json'
+ token.write_text('{}')
+ creds = tmp_path / 'creds.json'
+ creds.write_text('{}')
+ mock_credentials.from_authorized_user_file.return_value = MagicMock(valid=True)
+
+ with patch.dict('os.environ', {'HOME': str(tmp_path)}):
+ OAuthFileCredentials('test', '~/creds.json', '$HOME/token.json', scopes=TEST_SCOPES)
+
+ mock_credentials.from_authorized_user_file.assert_called_once_with(str(token))
+
+ def test_corrupt_token_file_falls_back_gracefully(self, tmp_path: Path) -> None:
+ """Corrupt or truncated token.json logs warning and leaves creds unloaded instead of crashing."""
+ token = tmp_path / 'token.json'
+ token.write_text('not valid json{')
+ creds = tmp_path / 'creds.json'
+ creds.write_text('{}')
+
+ provider = OAuthFileCredentials('test', str(creds), token, scopes=TEST_SCOPES, interactive=False)
+ assert provider.needs_auth
+ assert not provider.valid
--
2.56.0.542.g3ef89b7020-goog
next prev parent reply other threads:[~2026-11-11 6:59 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-11 6:59 [PATCH kgl 0/2] Add token_command support for Gmail targets Dmitry Torokhov
2026-11-11 6:59 ` Dmitry Torokhov [this message]
2026-11-11 6:59 ` [PATCH kgl 2/2] " Dmitry Torokhov
2026-11-12 19:58 ` [PATCH kgl 0/2] " Konstantin Ryabitsev
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261110-token-command-v1-1-a089145494d3@gmail.com \
--to=dmitry.torokhov@gmail.com \
--cc=konstantin@linuxfoundation.org \
--cc=tools@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.