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 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.