tools.linux.kernel.org archive mirror
 help / color / mirror / Atom feed
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


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