From: Dmitry Torokhov <dmitry.torokhov@gmail.com>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
Subject: [PATCH kgl 2/2] Add token_command support for Gmail targets
Date: Tue, 10 Nov 2026 22:59:19 -0800 [thread overview]
Message-ID: <20261110-token-command-v1-2-a089145494d3@gmail.com> (raw)
In-Reply-To: <20261110-token-command-v1-0-a089145494d3@gmail.com>
Add support for obtaining Gmail OAuth access tokens from an external
command via the token_command configuration setting on Gmail targets.
This enables integrating with system token managers, secret stores, or
command-line helpers without needing Google OAuth client secrets or
running interactive OAuth login flows in a browser.
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
docs/configuration.rst | 10 +++-
korgalore-example.toml | 2 +
man/korgalore.toml.5 | 12 ++++-
src/korgalore/cli.py | 12 ++++-
src/korgalore/gmail_target.py | 27 +++++++---
src/korgalore/google_creds.py | 111 +++++++++++++++++++++++++++++++++++++++-
tests/test_cli_misc.py | 103 +++++++++++++++++++++++++++++++++++++-
tests/test_gmail_target.py | 35 +++++++++++--
tests/test_google_creds.py | 114 ++++++++++++++++++++++++++++++++++++++++--
9 files changed, 404 insertions(+), 22 deletions(-)
diff --git a/docs/configuration.rst b/docs/configuration.rst
index fbba776..734ab26 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -410,9 +410,17 @@ Gmail Target Parameters
~~~~~~~~~~~~~~~~~~~~~~~~
* ``type``: Must be ``'gmail'``
-* ``credentials``: Path to the OAuth credentials JSON file
+* ``credentials``: Path to the OAuth credentials JSON file (required unless
+ ``token_command`` is specified)
* ``token``: (Optional) Path to store the OAuth token. Defaults to
``~/.config/korgalore/gmail-{identifier}-token.json``
+* ``token_command``: (Optional) External command that prints a valid OAuth2
+ bearer token to stdout. Mutually exclusive with both ``credentials`` and
+ ``token``. The command string is split using standard command-line syntax
+ and executed directly without a shell, with tilde and environment variable
+ expansion. The bearer token returned by the command must carry the OAuth
+ scopes required by korgalore (``https://www.googleapis.com/auth/gmail.labels``
+ and ``https://www.googleapis.com/auth/gmail.insert``).
Maildir Target Parameters
~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/korgalore-example.toml b/korgalore-example.toml
index b7d8164..0551bc4 100644
--- a/korgalore-example.toml
+++ b/korgalore-example.toml
@@ -3,6 +3,8 @@
[targets.personal]
type = 'gmail'
credentials = '/path/to/personal-credentials.json'
+# Alternatively, obtain tokens from an external command:
+# token_command = 'oama access personal'
[targets.work]
type = 'gmail'
diff --git a/man/korgalore.toml.5 b/man/korgalore.toml.5
index f72d242..3d23a74 100644
--- a/man/korgalore.toml.5
+++ b/man/korgalore.toml.5
@@ -30,11 +30,21 @@ name and a type that determines which parameters are required.
Must be \fI'gmail'\fR.
.TP
.B credentials
-Path to the OAuth credentials JSON file from Google Cloud Console.
+Path to the OAuth credentials JSON file from Google Cloud Console (required
+unless \fBtoken_command\fR is specified).
.TP
.B token
(Optional) Path to store the OAuth token. Defaults to
\fI~/.config/korgalore/gmail-<name>-token.json\fR.
+.TP
+.B token_command
+(Optional) External command to execute that prints an OAuth2 bearer token to
+stdout. Mutually exclusive with both \fBcredentials\fR and \fBtoken\fR.
+The command is split into arguments using standard command-line syntax and
+executed directly without a shell, with tilde and environment variable
+expansion. The bearer token must carry the OAuth scopes required by
+korgalore (\fIhttps://www.googleapis.com/auth/gmail.labels\fR and
+\fIhttps://www.googleapis.com/auth/gmail.insert\fR).
.RE
.PP
.B Maildir Targets
diff --git a/src/korgalore/cli.py b/src/korgalore/cli.py
index 180906d..e98d4be 100644
--- a/src/korgalore/cli.py
+++ b/src/korgalore/cli.py
@@ -1948,6 +1948,9 @@ def auth(ctx: click.Context, target: str | None) -> None:
ts = get_target(ctx, identifier)
ts.connect()
logger.info('Authenticated target: %s (type: %s)', identifier, target_type)
+ except (ConfigurationError, AuthenticationError, RemoteError) as e:
+ logger.error('Failed to authenticate target %s: %s', identifier, e)
+ raise click.Abort() from e
except click.Abort:
logger.error('Failed to authenticate target: %s', identifier)
raise
@@ -2483,7 +2486,12 @@ def yank(ctx: click.Context, target: str | None, labels: tuple[str, ...], thread
uploaded = 0
failed = 0
- ts.connect()
+ try:
+ ts.connect()
+ except (ConfigurationError, AuthenticationError, liblore.RemoteError) as e:
+ logger.critical('Failed to connect to target "%s": %s', target, e)
+ raise click.Abort() from e
+
with click.progressbar(
messages, label='Uploading thread', show_pos=True, file=progress_file(ctx.obj['hide_bar'])
) as bar:
@@ -2524,7 +2532,7 @@ def yank(ctx: click.Context, target: str | None, labels: tuple[str, ...], thread
ts.connect()
ts.import_message(raw_message, labels=labels_list)
logger.info('Successfully uploaded message.')
- except liblore.RemoteError as e:
+ except (ConfigurationError, AuthenticationError, liblore.RemoteError) as e:
logger.critical('Failed to upload message: %s', str(e))
raise click.Abort() from e
diff --git a/src/korgalore/gmail_target.py b/src/korgalore/gmail_target.py
index 3fd9f8b..868e148 100644
--- a/src/korgalore/gmail_target.py
+++ b/src/korgalore/gmail_target.py
@@ -11,7 +11,7 @@ from googleapiclient.discovery import Resource, build # type: ignore[import-unt
from googleapiclient.errors import HttpError # type: ignore[import-untyped]
from korgalore import AuthenticationError, ConfigurationError, RemoteError
-from korgalore.google_creds import OAuthFileCredentials
+from korgalore.google_creds import CommandCredentials, OAuthFileCredentials, parse_token_command
from korgalore.message import RawMessage
logger = logging.getLogger('korgalore')
@@ -53,8 +53,11 @@ class GmailTarget:
Creates the Gmail API service object if not already connected.
Raises:
- ConfigurationError: If credentials file is not found during refresh.
+ ConfigurationError: If credentials file is missing or token_command
+ cannot be executed.
AuthenticationError: If OAuth token refresh or authentication fails.
+ RemoteError: If token_command fails, times out, or returns an
+ invalid token.
"""
if self.service is not None:
return
@@ -201,11 +204,23 @@ def make_gmail_credentials(
Configured credentials instance compatible with ``GmailTarget``.
Raises:
- ConfigurationError: If required configuration keys are missing or have
- invalid types.
+ ConfigurationError: If required configuration keys are missing,
+ mutually exclusive, or invalid.
"""
- if 'credentials' not in details:
- raise ConfigurationError(f"No credentials file specified for Gmail target '{identifier}'.")
+ has_creds = 'credentials' in details
+ has_token = 'token' in details
+ has_cmd = 'token_command' in details
+
+ if has_cmd:
+ if has_creds or has_token:
+ raise ConfigurationError(
+ f"Gmail target '{identifier}' cannot specify both 'token_command' and 'credentials'/'token'."
+ )
+ command = parse_token_command(details['token_command'], f"Gmail target '{identifier}'")
+ return CommandCredentials(identifier=identifier, command=command)
+
+ if not has_creds:
+ raise ConfigurationError(f"Gmail target '{identifier}' must specify either 'credentials' or 'token_command'.")
creds_raw = details['credentials']
if not isinstance(creds_raw, str):
diff --git a/src/korgalore/google_creds.py b/src/korgalore/google_creds.py
index f1963bb..0818244 100644
--- a/src/korgalore/google_creds.py
+++ b/src/korgalore/google_creds.py
@@ -1,7 +1,9 @@
-"""Google credential providers for korgalore."""
+"""Google OAuth2 and command credential providers for korgalore."""
import logging
import os
+import shlex
+import subprocess
from collections.abc import Sequence
from pathlib import Path
from typing import Any
@@ -13,7 +15,7 @@ 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
+from korgalore import AuthenticationError, ConfigurationError, RemoteError
logger = logging.getLogger('korgalore')
@@ -227,3 +229,108 @@ class OAuthFileCredentials(BaseCredentials):
) from e
self._save_token()
self.needs_auth = False
+
+
+def parse_token_command(command: Any, context: str) -> list[str]:
+ """Validate and parse a ``token_command`` value into an argument vector.
+
+ Args:
+ command: Raw configuration value expected to be a non-empty command string.
+ context: Human-readable caller label for error messages.
+
+ Returns:
+ Argument vector with tilde and environment variables expanded.
+
+ Raises:
+ ConfigurationError: If ``command`` is not a string, has invalid shell
+ quoting, or splits into an empty argument list.
+ """
+ if not isinstance(command, str):
+ raise ConfigurationError(f"{context}: 'token_command' must be a string.")
+ try:
+ raw_args = shlex.split(command)
+ except ValueError as e:
+ raise ConfigurationError(f"Invalid 'token_command' syntax for {context}: {e}") from e
+ if not raw_args:
+ raise ConfigurationError(f"{context}: 'token_command' cannot be empty.")
+ return [os.path.expanduser(os.path.expandvars(arg)) for arg in raw_args] # noqa: PTH111
+
+
+def run_token_command(command: Sequence[str], context: str, timeout: int = 30) -> str:
+ """Execute an external token helper and return its bearer token output.
+
+ Runs ``command`` directly without a shell and verifies that standard output
+ contains a single non-empty, whitespace-free token.
+
+ Args:
+ command: Argument vector passed directly to ``subprocess.run``.
+ context: Human-readable caller label for debug logs and errors.
+ timeout: Maximum execution time in seconds before failing.
+
+ Returns:
+ Stripped bearer token read from standard output.
+
+ Raises:
+ ConfigurationError: If the executable cannot be launched.
+ RemoteError: If the command times out, exits non-zero, or returns an
+ empty or whitespace-containing token.
+ """
+ try:
+ res = subprocess.run(
+ command,
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding='utf-8',
+ errors='replace',
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired as e:
+ raise RemoteError(f'token_command timed out after {timeout}s for {context}') from e
+ except OSError as e:
+ raise ConfigurationError(f'Failed to execute token_command for {context}: {e}') from e
+
+ err_msg = ''
+ if res.stderr:
+ logger.debug('token_command stderr for %s:\n%s', context, res.stderr)
+ lines = [line.strip() for line in res.stderr.splitlines() if line.strip()]
+ if lines:
+ err_msg = f': {lines[-1]}'
+
+ if res.returncode != 0:
+ raise RemoteError(f'token_command for {context} failed with exit code {res.returncode}{err_msg}')
+
+ token = res.stdout.strip()
+ if not token or any(c.isspace() for c in token):
+ raise RemoteError(f'token_command for {context} returned empty or invalid token{err_msg}')
+
+ return token
+
+
+class CommandCredentials(BaseCredentials):
+ """Credentials obtained by executing an external token command."""
+
+ def __init__(self, identifier: str, command: Sequence[str]) -> None:
+ """Initialize command-backed bearer credentials.
+
+ Args:
+ identifier: Unique identifier for the target.
+ command: Parsed command argument vector to run on refresh.
+ """
+ super().__init__() # type: ignore[no-untyped-call]
+ self.identifier = identifier
+ self.command = list(command)
+
+ def refresh(self, request: Any = None) -> None:
+ """Run the configured token command and capture the bearer token.
+
+ Args:
+ request: Ignored; present to satisfy the ``google.auth`` interface.
+
+ Raises:
+ ConfigurationError: If the token command cannot be executed.
+ RemoteError: If the token command fails, times out, or emits invalid
+ output.
+ """
+ self.token = run_token_command(self.command, f"target '{self.identifier}'")
diff --git a/tests/test_cli_misc.py b/tests/test_cli_misc.py
index e351b0b..6951521 100644
--- a/tests/test_cli_misc.py
+++ b/tests/test_cli_misc.py
@@ -10,12 +10,16 @@ noise into somebody's debug log.
"""
import io
+import subprocess
+from unittest.mock import patch
import click
import pytest
+from click.testing import CliRunner
-from korgalore import format_key_for_display
-from korgalore.cli import progress_file
+from korgalore import RemoteError, format_key_for_display
+from korgalore.cli import auth, perform_yank, progress_file, yank
+from tests.digest_helpers import make_ctx
@pytest.mark.parametrize(
@@ -75,3 +79,98 @@ def test_hidden_bar_writes_nothing_to_stdout(capsys: pytest.CaptureFixture[str])
# stream is what keeps it off the user's terminal, so check it landed here.
assert isinstance(stream, io.StringIO)
assert 'Hiding' in stream.getvalue()
+
+
+def test_auth_token_command_failure_aborts_cleanly() -> None:
+ runner = CliRunner()
+ ctx_obj = {
+ 'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+ 'targets': {},
+ }
+ with patch('korgalore.google_creds.subprocess.run') as mock_run:
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=1, stdout='', stderr='command failed'
+ )
+ res = runner.invoke(auth, ['test'], obj=ctx_obj)
+ assert res.exit_code == 1
+ assert res.exception is not None
+ assert isinstance(res.exception, SystemExit)
+ mock_run.assert_called_once()
+
+
+def test_auth_token_command_verifies_labels() -> None:
+ runner = CliRunner()
+ ctx_obj = {
+ 'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+ 'targets': {},
+ }
+ with (
+ patch('korgalore.google_creds.subprocess.run') as mock_run,
+ patch('korgalore.gmail_target.GmailTarget.list_labels') as mock_list_labels,
+ patch('korgalore.gmail_target.build'),
+ ):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=0, stdout='valid-token\n', stderr=''
+ )
+ res = runner.invoke(auth, ['test'], obj=ctx_obj)
+ assert res.exit_code == 0
+ mock_run.assert_called_once()
+ mock_list_labels.assert_called_once()
+
+
+def test_yank_token_command_failure_aborts_cleanly() -> None:
+ runner = CliRunner()
+ ctx_obj = {
+ 'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+ 'targets': {},
+ 'hide_bar': True,
+ }
+ with (
+ patch('korgalore.google_creds.subprocess.run') as mock_run,
+ patch('korgalore.cli.get_lore_node') as mock_node,
+ ):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=1, stdout='', stderr='command failed'
+ )
+ mock_node.return_value.get_mbox_by_msgid.return_value = b'From mboxrd@z Thu Jan 1 00:00:00 1970\n\n'
+ res = runner.invoke(yank, ['--target', 'test', '--thread', 'some-id'], obj=ctx_obj)
+ assert res.exit_code == 1
+ assert res.exception is not None
+ assert isinstance(res.exception, SystemExit)
+ mock_run.assert_called_once()
+
+
+def test_yank_single_message_command_oserror_aborts_cleanly() -> None:
+ runner = CliRunner()
+ ctx_obj = {
+ 'config': {'targets': {'test': {'type': 'gmail', 'token_command': '/nonexistent/get-token'}}},
+ 'targets': {},
+ 'hide_bar': True,
+ }
+ with (
+ patch('korgalore.google_creds.subprocess.run') as mock_run,
+ patch('korgalore.cli.get_lore_node') as mock_node,
+ ):
+ mock_run.side_effect = FileNotFoundError('No such file')
+ mock_node.return_value.get_message_by_msgid.return_value = b'From: a@b\nSubject: x\n\nBody\n'
+ res = runner.invoke(yank, ['--target', 'test', 'some-msgid'], obj=ctx_obj)
+ assert res.exit_code == 1
+ assert res.exception is not None
+ assert isinstance(res.exception, SystemExit)
+ mock_run.assert_called_once()
+
+
+def test_perform_yank_connection_failure_raises_remote_error() -> None:
+ ctx = make_ctx(
+ {
+ 'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+ 'targets': {},
+ 'hide_bar': True,
+ }
+ )
+ with patch('korgalore.google_creds.subprocess.run') as mock_run:
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=1, stdout='', stderr='command failed'
+ )
+ with pytest.raises(RemoteError):
+ perform_yank(ctx, 'test', 'some-msgid')
diff --git a/tests/test_gmail_target.py b/tests/test_gmail_target.py
index 9e27f1c..3c5dfb2 100644
--- a/tests/test_gmail_target.py
+++ b/tests/test_gmail_target.py
@@ -10,7 +10,7 @@ from googleapiclient.errors import HttpError # type: ignore[import-untyped]
from korgalore import AuthenticationError, ConfigurationError, RemoteError
from korgalore.gmail_target import SCOPES, GmailTarget, make_gmail_credentials
-from korgalore.google_creds import OAuthFileCredentials
+from korgalore.google_creds import CommandCredentials, OAuthFileCredentials
def make_target() -> tuple[GmailTarget, MagicMock]:
@@ -300,18 +300,43 @@ class TestGmailTargetImportMessage:
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'):
+ def test_no_credentials_or_token_command_raises(self) -> None:
+ """Must specify either credentials or token_command."""
+ with pytest.raises(ConfigurationError, match='must specify either'):
make_gmail_credentials('test', {})
+ @pytest.mark.parametrize(
+ 'details',
+ [
+ {'credentials': '/tmp/creds.json', 'token_command': 'echo token'},
+ {'token': '/tmp/token.json', 'token_command': 'echo token'},
+ ],
+ ids=['credentials-and-command', 'token-and-command'],
+ )
+ def test_mutually_exclusive_token_command_options_raise(self, details: dict[str, object]) -> None:
+ """Specifying token_command alongside credentials or token raises ConfigurationError."""
+ with pytest.raises(ConfigurationError, match='cannot specify both'):
+ make_gmail_credentials('test', details)
+
+ def test_token_command_invalid_syntax_raises(self) -> None:
+ """Unclosed quotes in token_command raise ConfigurationError."""
+ with pytest.raises(ConfigurationError, match='syntax'):
+ make_gmail_credentials('test', {'token_command': 'unclosed "quote'})
+
+ def test_token_command_returns_command_credentials(self) -> None:
+ """Valid token_command builds CommandCredentials without running interactive OAuth."""
+ creds = make_gmail_credentials('test', {'token_command': 'get-token --profile work'})
+ assert isinstance(creds, CommandCredentials)
+ assert creds.command == ['get-token', '--profile', 'work']
+
@pytest.mark.parametrize(
('details', 'expected_match'),
[
({'credentials': 123}, "'credentials' must be a string"),
({'credentials': '/tmp/creds.json', 'token': 456}, "'token' must be a string"),
+ ({'token_command': 789}, "'token_command' must be a string"),
],
- ids=['non-str-credentials', 'non-str-token'],
+ ids=['non-str-credentials', 'non-str-token', 'non-str-token-command'],
)
def test_invalid_option_types_raise(self, details: dict[str, object], expected_match: str) -> None:
"""Non-string configuration values raise ConfigurationError."""
diff --git a/tests/test_google_creds.py b/tests/test_google_creds.py
index 4ac0a3d..f392a36 100644
--- a/tests/test_google_creds.py
+++ b/tests/test_google_creds.py
@@ -1,13 +1,19 @@
-"""Tests for Google OAuth2 credential providers."""
+"""Tests for Google OAuth2 and token_command credential providers."""
+import subprocess
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
+from korgalore import AuthenticationError, ConfigurationError, RemoteError
+from korgalore.google_creds import (
+ CommandCredentials,
+ OAuthFileCredentials,
+ parse_token_command,
+ run_token_command,
+)
TEST_SCOPES = ['https://example.invalid/scope.a', 'https://example.invalid/scope.b']
@@ -152,3 +158,105 @@ class TestOAuthFileCredentials:
provider = OAuthFileCredentials('test', str(creds), token, scopes=TEST_SCOPES, interactive=False)
assert provider.needs_auth
assert not provider.valid
+
+
+class TestTokenCommandHelpers:
+ """Tests for target-agnostic parse_token_command and run_token_command helpers."""
+
+ def test_parse_expands_env_and_home(self, tmp_path: Path) -> None:
+ """Splits quoted args and expands environment variables and tildes."""
+ with patch.dict('os.environ', {'HOME': str(tmp_path), 'HELPER_ARG': 'work account'}):
+ args = parse_token_command('~/bin/get-token --profile "$HELPER_ARG"', "target 'test'")
+
+ assert args == [f'{tmp_path}/bin/get-token', '--profile', 'work account']
+
+ @pytest.mark.parametrize(
+ ('value', 'expected_match'),
+ [
+ (123, 'must be a string'),
+ (' ', 'cannot be empty'),
+ ('unclosed "quote', 'syntax'),
+ ],
+ ids=['non-string', 'whitespace-only', 'bad-quoting'],
+ )
+ def test_parse_invalid_values_raise(self, value: object, expected_match: str) -> None:
+ """Invalid command strings raise ConfigurationError."""
+ with pytest.raises(ConfigurationError, match=expected_match):
+ parse_token_command(value, "target 'test'")
+
+ @patch('korgalore.google_creds.subprocess.run')
+ def test_run_returns_stripped_token(self, mock_run: MagicMock) -> None:
+ """Executes command without a shell and returns the stripped bearer token."""
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=0, stdout=' valid-bearer-token\n', stderr=''
+ )
+
+ assert run_token_command(['get-token'], "target 'test'") == 'valid-bearer-token'
+ mock_run.assert_called_once_with(
+ ['get-token'],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding='utf-8',
+ errors='replace',
+ timeout=30,
+ check=False,
+ )
+
+ @patch('korgalore.google_creds.subprocess.run')
+ def test_run_nonzero_exit_raises_remote_error(self, mock_run: MagicMock) -> None:
+ """Non-zero exit raises RemoteError including the last non-empty stderr line."""
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=1, stdout='', stderr='ignored line\nauth failed\n'
+ )
+
+ with pytest.raises(RemoteError, match='auth failed'):
+ run_token_command(['get-token'], "target 'test'")
+
+ @pytest.mark.parametrize(
+ 'stdout',
+ [' \n', 'multi word token\n'],
+ ids=['empty', 'embedded-whitespace'],
+ )
+ @patch('korgalore.google_creds.subprocess.run')
+ def test_run_invalid_output_raises_remote_error(self, mock_run: MagicMock, stdout: str) -> None:
+ """Empty or whitespace-containing stdout raises RemoteError."""
+ mock_run.return_value = subprocess.CompletedProcess(args=['get-token'], returncode=0, stdout=stdout, stderr='')
+
+ with pytest.raises(RemoteError, match='empty or invalid'):
+ run_token_command(['get-token'], "target 'test'")
+
+ @patch('korgalore.google_creds.subprocess.run')
+ def test_run_timeout_raises_remote_error(self, mock_run: MagicMock) -> None:
+ """Subprocess timeout raises RemoteError."""
+ mock_run.side_effect = subprocess.TimeoutExpired(cmd='get-token', timeout=30)
+
+ with pytest.raises(RemoteError, match='timed out after 30s'):
+ run_token_command(['get-token'], "target 'test'")
+
+ @patch('korgalore.google_creds.subprocess.run')
+ def test_run_oserror_raises_configuration_error(self, mock_run: MagicMock) -> None:
+ """Missing executable raises ConfigurationError."""
+ mock_run.side_effect = FileNotFoundError('No such file or directory')
+
+ with pytest.raises(ConfigurationError, match='Failed to execute'):
+ run_token_command(['nonexistent-cmd'], "target 'test'")
+
+
+class TestCommandCredentials:
+ """Tests for CommandCredentials."""
+
+ @patch('korgalore.google_creds.subprocess.run')
+ def test_refresh_sets_token_and_marks_valid(self, mock_run: MagicMock) -> None:
+ """CommandCredentials.refresh() invokes run_token_command and populates token."""
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=['get-token'], returncode=0, stdout='valid-bearer-token\n', stderr=''
+ )
+
+ creds = CommandCredentials('test', ['get-token'])
+ assert not creds.valid
+
+ creds.refresh(None)
+
+ assert creds.valid
+ assert creds.token == 'valid-bearer-token'
--
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 ` [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management Dmitry Torokhov
2026-11-11 6:59 ` Dmitry Torokhov [this message]
2026-11-12 19:58 ` [PATCH kgl 0/2] Add token_command support for Gmail targets 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-2-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).