diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst
index fffaaf7..ed5eb5b 100644
--- a/docs/CHANGELOG.rst
+++ b/docs/CHANGELOG.rst
@@ -8,6 +8,8 @@ The format is based on `Keep a Changelog `
Current (Unreleased)
--------------------
+- Add mocked compatibility tests, ``auth status`` / ``auth logout``, and optional domain-wide delegation ``--subject`` without changing existing commands or store keys.
+- Record packaging updates and install from the lockfile in CI. (`#93 `_) [`@terrancedejesus `_]
- Ignore local AI and planning markdown files. (`#90 `_) [`@terrancedejesus `_]
- Pin Python dependencies to current 3.10-compatible versions and add ``poetry.lock``. (`#91 `_) [`@terrancedejesus `_]
- Refresh ReadTheDocs requirements to the pinned Sphinx versions. (`#92 `_) [`@terrancedejesus `_]
diff --git a/docs/auth.rst b/docs/auth.rst
index 2abb850..0cb54d0 100644
--- a/docs/auth.rst
+++ b/docs/auth.rst
@@ -10,7 +10,7 @@ Google Workspace Authentication Mechanisms
- **Service Accounts**: Representing non-human users, these accounts authenticate via dedicated credentials. Service accounts are ideal for server-to-server interactions or acting on behalf of a user. Service accounts require domain-wide delegation to access Google Workspace APIs. Unlike OAuth, service account scopes are defined at the time of creation or based on the IAM role assigned. Therefore to use service accounts in SWAT, the user must create a service account with the appropriate scopes and download the JSON key file.
-- **API Keys**: These keys authenticate requests to Google Workspace APIs.
+- **API Keys**: Google API keys are not used for SWAT authentication. SWAT authenticates with OAuth client credentials or service accounts.
For SWAT, both OAuth credentials and Service Account credentials are supported.
@@ -51,11 +51,16 @@ The `Cred` class pairs these credentials with an active session if one exists. T
Command Execution with `auth.py`
--------------------------------
-`auth.py` provides the executable logic, allowing the user to:
+``auth.py`` provides the executable logic. Existing commands stay in place:
-- Initiate authentication using either OAuth or Service Account.
-- Fetch, store, or remove credentials in the `CredStore`.
-- Authenticates and authorizes to Google Workspace services with a `Cred` object.
+- ``auth session`` authenticates with OAuth or a service account and optionally stores the session under a key.
+- ``auth list`` lists stored sessions.
+- ``auth status`` shows stored keys, credential type, session state, and a non-secret identity. Use ``--key`` to inspect one entry.
+- ``auth logout [KEY]`` clears the stored session for a key and leaves the credential in the store. The default key is ``default``. Logout is local only; it does not revoke tokens at Google.
+
+``auth session --service-account --subject USER@DOMAIN`` impersonates that Workspace user through domain-wide delegation. ``--subject`` requires ``--service-account``.
+
+Credentials are added and removed with ``creds add/remove/list``. Those commands are unchanged.
Google Workspace Scopes
-----------------------
@@ -84,7 +89,7 @@ For more information on how to authenticate or authorize with credentials in SWA
Persistence of Credentials
--------------------------
-Credentials are stored in a serialized file, `swat/etc/.cred_store.pkl`, by default. Both credentials and valid sessions are stored in this file and are loaded at runtime. This allows for persistent authentication and authorization without the need to re-authenticate each time for every user or service account. This can be disabled by changed the `store_on_exit` value in the `etc/config.yaml` to `False`.
+Credentials are stored in a serialized file, `swat/etc/.cred_store.pkl`, by default. Both credentials and valid sessions are stored in this file and are loaded at runtime. This allows for persistent authentication and authorization without the need to re-authenticate each time for every user or service account. This can be disabled by changing the `save_on_exit` value in the `etc/config.yaml` to `False`.
Recommendations
---------------
@@ -94,7 +99,7 @@ Recommendations
- Some emulations require a "3rd-party" google workspace account, separate from the organization being targeted/tested. It is therefore recommended to setup a separate and external Google Workspace account for this purpose.
- For the "3rd-party" Google Workspace account, it is recommended to use OAuth credentials as well and store them in the credential store with the key name `external` as such ``auth session --store-key external --creds PATH_TO_CREDS``.
- It is recommended to have all Google Chrome profiles established and pre-authenticated for users where OAuth credentials are used. This will be useful when the OAuth consent screen appears and the user can select the appropriate profile to authenticate with.
-- It is recommended to keep the `store_on_exit` value in the `etc/config.yaml` to `True` to ensure credentials are stored and available for future use.
+- It is recommended to keep the `save_on_exit` value in the `etc/config.yaml` to `True` to ensure credentials are stored and available for future use.
- Most OOTB emulations require 1-2 users with OAuth creds to emulate either internal or external user activity.
diff --git a/docs/how_to_guides.rst b/docs/how_to_guides.rst
index 7a26e71..42ce933 100644
--- a/docs/how_to_guides.rst
+++ b/docs/how_to_guides.rst
@@ -34,11 +34,13 @@ By default, SWAT will save the ``CredentialStore`` to a local file named ``.cred
Authentication and Authorization
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- Add Creds, Authenticate and Store Session: Use ``auth session --creds PATH_TO_CREDENTIALS_FILE --store NAME`` (`--service-account` if service account).
-- Remove Creds & Sessions from Store: Use ``creds remove NAME``.
-- List Credentials: Use ``creds list`` to view the credential store.
-- List Auth Sessions: Use ``auth list`` to view valid sessions.
-- Add Additional Creds and Session to Store:** Repeat the first note above with separate credentials.
+- Add creds, authenticate, and store a session: Use ``auth session --creds PATH_TO_CREDENTIALS_FILE --store-key NAME`` (``--service-account`` if service account). For domain-wide delegation, add ``--subject USER@DOMAIN``.
+- Check stored keys and session state: Use ``auth status``.
+- Clear a session without removing creds: Use ``auth logout NAME``.
+- Remove creds and sessions from the store: Use ``creds remove NAME``.
+- List credentials: Use ``creds list`` to view the credential store.
+- List auth sessions: Use ``auth list`` to view valid sessions.
+- Add additional creds and sessions to the store: Repeat the first note above with separate credentials.
Specific Authentication Steps
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -54,8 +56,8 @@ For most of the SWAT commands and emulations, a single OAuth validated session w
1. Run ``auth list`` - There should be no active sessions available, unless your credential store has been loaded from previous session
2. Run ``creds list`` - There should be no saved credentials, unless your credential store has been loaded from a previous session
-3. Run ``auth session --store default --creds PATH_TO_CREDS`` - where `default` is the key in the credential store for these creds and session
- #. This can also be used to store credentials, authenticate/authorize and store the session in the Credential Store at once. So if you want to bypass steps 1 and 2, you can run ``auth session --store default --creds PATH_TO_CREDS``.
+3. Run ``auth session --store-key default --creds PATH_TO_CREDS`` - where `default` is the key in the credential store for these creds and session
+ #. This can also be used to store credentials, authenticate/authorize and store the session in the Credential Store at once. So if you want to bypass steps 1 and 2, you can run ``auth session --store-key default --creds PATH_TO_CREDS``.
4. Follow the Google Workspace OAuth prompt and consent screen with a valid account.
.. image:: _static/auth_session_store.png
@@ -85,7 +87,7 @@ Once complete, you may continue to the following workflows, however be aware tha
#. From the `Google Cloud Console `_, navigate to the service account you want to use and click **Add Key**.
#. Select **JSON** and click **Create**. This will download the service account credentials to your local machine.
#. You can also create a new service account in the console and assign access to users whom this service account will make API calls on behalf of.
-2. Authenticate and Authorized: Run ``auth session --store-key KEYNAME --creds PATH_TO_CREDS --service-account``
+2. Authenticate and authorize: Run ``auth session --store-key KEYNAME --creds PATH_TO_CREDS --service-account --subject USER@DOMAIN``
3. Run ``auth list`` to check that session is saved
4. Run ``creds list`` to ensure creds are saved
5. Within your emulation, pass your session from the credential store to build your service in the initialization method ``self.service = build('drive', 'v3', credentials=self.obj.cred_store.store['KEYNAME'].session)``
@@ -108,7 +110,7 @@ Your `self.service` will now be active and authenticated and can be used anywher
:width: 600px
:alt: Add Service Account Credentials to Credential Store
-4. Run ``auth session --key KEYNAME --store-key KEYNAME --service-account``
+4. Run ``auth session --key KEYNAME --store-key KEYNAME --service-account --subject USER@DOMAIN``
6. Build your service in the initialization method of the emulation: ``self.service = build('drive', 'v3', credentials=self.obj.cred_store.store['KEYNAME'].session``
.. image:: _static/auth_session_from_cred_store.png
diff --git a/swat/base.py b/swat/base.py
index 0e38560..d312f1c 100644
--- a/swat/base.py
+++ b/swat/base.py
@@ -152,6 +152,13 @@ def remove(self, key: str) -> bool:
"""Remove cred by key and type."""
return self.store.pop(key, None) is not None
+ def clear_session(self, key: str) -> bool:
+ """Clear a stored session while leaving the credential in place."""
+ if key not in self.store or self.store[key].session is None:
+ return False
+ self.store[key].session = None
+ return True
+
def get(self, key: str, validate_type: Optional[Literal['oauth', 'service']] = None,
missing_error: bool = True) -> Optional[Cred]:
if key not in self.store:
diff --git a/swat/commands/auth.py b/swat/commands/auth.py
index 57e23f0..6f61aee 100644
--- a/swat/commands/auth.py
+++ b/swat/commands/auth.py
@@ -44,19 +44,32 @@ class Command(BaseCommand):
parser_session.add_argument('--creds', type=Path, help='Path to the credentials file')
parser_session.add_argument('--service-account', action='store_true', help='Authenticate a service account')
parser_session.add_argument('--store-key', type=str, help='Add authenticated session to credential store with key')
+ parser_session.add_argument('--subject', help='Workspace user email to impersonate with domain-wide delegation')
parser_list = subparsers.add_parser('list', description='List credential sessions within the cred store',
help='List credential sessions within the cred store')
+ parser_status = subparsers.add_parser('status', description='Show stored credentials and session state',
+ help='Show stored credentials and session state')
+ parser_status.add_argument('--key', help='Show a single stored key')
+ parser_logout = subparsers.add_parser('logout', description='Clear a stored session without removing credentials',
+ help='Clear a stored session without removing credentials')
+ parser_logout.add_argument('key', nargs='?', default='default',
+ help='Name of key to clear the session from (default: default)')
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.parser_session.set_defaults(func=self.authenticate)
self.parser_list.set_defaults(func=self.list_sessions)
+ self.parser_status.set_defaults(func=self.status)
+ self.parser_logout.set_defaults(func=self.logout)
self.args = validate_args(self.parser, self.args)
def authenticate(self) -> Optional[Credentials]:
"""Authenticate with Google Workspace using OAuth2.0."""
cred_type = 'service' if self.args.service_account else 'oauth'
+ if self.args.subject and not self.args.service_account:
+ self.logger.info('The --subject flag requires --service-account.')
+ return None
if self.args.key:
cred = self.obj.cred_store.get(self.args.key, validate_type=cred_type)
self.logger.info(f'Using stored credentials with key: {self.args.key}')
@@ -87,6 +100,9 @@ def authenticate(self) -> Optional[Credentials]:
self.logger.info(f'Missing key or credentials file.')
return None
+ if session and self.args.subject:
+ session = session.with_subject(self.args.subject)
+
self.logger.info(f'Authenticated successfully.' if session else f'Failed to authenticate.')
if self.args.store_key:
self.obj.cred_store.add(self.args.store_key, creds=cred, session=session, type=cred_type, override=True)
@@ -96,5 +112,43 @@ def list_sessions(self):
cred_sessions = self.obj.cred_store.list_sessions()
self.logger.info(f'Stored auth sessions: {", ".join(cred_sessions) if cred_sessions else None}')
+ def status(self):
+ store = self.obj.cred_store.store
+ keys = [self.args.key] if self.args.key else list(store)
+ if self.args.key and self.args.key not in store:
+ self.logger.info(f'No credentials found with key: {self.args.key}')
+ return
+ if not keys:
+ self.logger.info('No credentials stored.')
+ return
+ for key in keys:
+ self.logger.info(self._format_status(key, store[key]))
+
+ def logout(self):
+ cleared = self.obj.cred_store.clear_session(self.args.key)
+ if cleared:
+ self.logger.info(f'Cleared session for key: {self.args.key}')
+ elif self.args.key not in self.obj.cred_store.store:
+ self.logger.info(f'No credentials found with key: {self.args.key}')
+ else:
+ self.logger.info(f'No session stored for key: {self.args.key}')
+
+ @staticmethod
+ def _format_status(key: str, cred) -> str:
+ if isinstance(cred.creds, OAuthCreds):
+ cred_type = 'oauth'
+ identity = cred.client_id
+ elif isinstance(cred.creds, ServiceAccountCreds):
+ cred_type = 'service'
+ identity = cred.creds.client_email
+ else:
+ cred_type = 'unknown'
+ identity = cred.client_id
+ if cred.session:
+ session_state = 'expired' if getattr(cred.session, 'expired', False) else 'active'
+ else:
+ session_state = 'none'
+ return f'{key}: type={cred_type} session={session_state} identity={identity}'
+
def execute(self) -> None:
self.args.func()
diff --git a/tests/test_auth_compat.py b/tests/test_auth_compat.py
new file mode 100644
index 0000000..f223f6c
--- /dev/null
+++ b/tests/test_auth_compat.py
@@ -0,0 +1,303 @@
+import json
+import logging
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from swat.base import Cred, CredStore, OAuthCreds, SWAT, ServiceAccountCreds
+from swat.commands.auth import Command as AuthCommand
+from swat.commands.creds import Command as CredsCommand
+from swat.commands.scopes import Command as ScopesCommand
+from swat.shell import SWATShell
+
+
+OAUTH_CLIENT = {
+ 'installed': {
+ 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs',
+ 'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
+ 'client_id': 'oauth-client-id.apps.googleusercontent.com',
+ 'client_secret': 'TEST-NOT-A-SECRET',
+ 'project_id': 'swat-test',
+ 'redirect_uris': ['http://localhost'],
+ 'token_uri': 'https://oauth2.googleapis.com/token',
+ }
+}
+
+SERVICE_ACCOUNT = {
+ 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs',
+ 'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
+ 'client_email': 'swat-test@swat-test.iam.gserviceaccount.com',
+ 'client_id': 'sa-client-id',
+ 'client_x509_cert_url': 'https://www.googleapis.com/robot/v1/metadata/x509/swat-test',
+ 'private_key_id': 'test-key-id',
+ 'private_key': '-----BEGIN PRIVATE KEY-----\nTEST-NOT-A-SECRET\n-----END PRIVATE KEY-----\n',
+ 'project_id': 'swat-test',
+ 'token_uri': 'https://oauth2.googleapis.com/token',
+ 'type': 'service_account',
+ 'universe_domain': 'googleapis.com',
+}
+
+
+def write_json(path, payload):
+ path.write_text(json.dumps(payload))
+ return path
+
+
+@pytest.fixture
+def oauth_creds(tmp_path):
+ return OAuthCreds.from_file(write_json(tmp_path / 'client_secret.json', OAUTH_CLIENT))
+
+
+@pytest.fixture
+def service_creds(tmp_path):
+ return ServiceAccountCreds.from_file(write_json(tmp_path / 'service_account.json', SERVICE_ACCOUNT))
+
+
+@pytest.fixture
+def store():
+ return CredStore()
+
+
+class TestCredentialLoading:
+ def test_oauth_from_file_reads_installed_client(self, oauth_creds):
+ assert oauth_creds.client_id == 'oauth-client-id.apps.googleusercontent.com'
+ assert oauth_creds.client_secret == 'TEST-NOT-A-SECRET'
+ assert oauth_creds.to_dict()['installed']['client_id'] == oauth_creds.client_id
+
+ def test_service_account_from_file(self, service_creds):
+ assert service_creds.type == 'service_account'
+ assert service_creds.client_email == 'swat-test@swat-test.iam.gserviceaccount.com'
+ assert service_creds.client_id == 'sa-client-id'
+
+
+class TestCredStoreKeys:
+ def test_add_get_and_remove_default_key(self, store, oauth_creds):
+ store.add('default', creds=oauth_creds, type='oauth')
+
+ cred = store.get('default', validate_type='oauth')
+ assert cred.creds.client_id == oauth_creds.client_id
+ assert store.store['default'].session is None
+
+ assert store.remove('default') is True
+ assert store.remove('default') is False
+
+ def test_preserves_external_key(self, store, oauth_creds):
+ store.add('external', creds=oauth_creds, type='oauth')
+ assert 'external' in store.store
+ assert store.get('external').client_id == oauth_creds.client_id
+
+ def test_add_without_override_raises(self, store, oauth_creds):
+ store.add('default', creds=oauth_creds)
+ with pytest.raises(ValueError, match='Value exists for: default'):
+ store.add('default', creds=oauth_creds)
+
+ def test_add_with_override_replaces(self, store, oauth_creds, service_creds):
+ store.add('default', creds=oauth_creds)
+ store.add('default', creds=service_creds, override=True, type='service')
+ store.get('default', validate_type='service')
+
+ def test_get_missing_key_raises(self, store):
+ with pytest.raises(ValueError, match='Value not found for: missing'):
+ store.get('missing')
+
+ def test_validate_type_rejects_mismatch(self, store, oauth_creds):
+ store.add('default', creds=oauth_creds)
+ with pytest.raises(ValueError, match='is not ServiceAccountCreds'):
+ store.get('default', validate_type='service')
+
+ def test_get_by_client_id(self, store, oauth_creds):
+ store.add('default', creds=oauth_creds)
+ found = store.get_by_client_id(oauth_creds.client_id, validate_type='oauth')
+ assert found.client_id == oauth_creds.client_id
+
+ def test_legacy_session_attribute(self, store, oauth_creds):
+ session = SimpleNamespace(expired=False, refresh_token=None, client_id='session-client')
+ store.add('default', creds=oauth_creds, session=session)
+ assert store.store['default'].session is session
+ assert store.has_sessions is True
+
+ def test_list_credentials_includes_key(self, store, oauth_creds):
+ store.add('default', creds=oauth_creds)
+ listed = store.list_credentials()
+ assert listed
+ assert listed[0].startswith('default')
+
+ def test_clear_session_keeps_credential(self, store, oauth_creds):
+ session = SimpleNamespace(expired=False, refresh_token=None, client_id='session-client')
+ store.add('default', creds=oauth_creds, session=session)
+
+ assert store.clear_session('default') is True
+ assert store.store['default'].session is None
+ assert store.get('default', validate_type='oauth').client_id == oauth_creds.client_id
+ assert store.clear_session('default') is False
+ assert store.clear_session('missing') is False
+
+
+class TestCredStorePersistence:
+ def test_save_and_load_round_trip(self, tmp_path, oauth_creds):
+ path = tmp_path / 'cred_store.pkl'
+ original = CredStore(path=path)
+ original.add('default', creds=oauth_creds, type='oauth')
+ original.save()
+
+ loaded = CredStore.from_file(path)
+ assert loaded is not None
+ assert loaded.get('default', validate_type='oauth').client_id == oauth_creds.client_id
+
+ def test_from_file_missing_returns_none(self, tmp_path):
+ assert CredStore.from_file(tmp_path / 'missing.pkl') is None
+
+
+class TestCredSession:
+ def test_client_id_from_oauth_creds(self, oauth_creds):
+ assert Cred(creds=oauth_creds, session=None).client_id == oauth_creds.client_id
+
+ def test_refreshed_session_returns_unexpired_session(self, oauth_creds):
+ session = MagicMock()
+ session.expired = False
+ session.refresh_token = 'TEST-NOT-A-SECRET'
+ cred = Cred(creds=oauth_creds, session=session)
+
+ assert cred.refreshed_session() is session
+ session.refresh.assert_not_called()
+
+ def test_refreshed_session_refreshes_expired_token(self, oauth_creds):
+ session = MagicMock()
+ session.expired = True
+ session.refresh_token = 'TEST-NOT-A-SECRET'
+ cred = Cred(creds=oauth_creds, session=session)
+
+ assert cred.refreshed_session() is session
+ session.refresh.assert_called_once()
+
+
+class TestCommandParsers:
+ def test_auth_session_and_list_parsers(self):
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=CredStore())
+ session = AuthCommand(command='auth', args=['session', '--store-key', 'default'], obj=obj)
+ assert session.args.subcommand == 'session'
+ assert session.args.store_key == 'default'
+ assert session.args.subject is None
+
+ listed = AuthCommand(command='auth', args=['list'], obj=obj)
+ assert listed.args.subcommand == 'list'
+
+ status = AuthCommand(command='auth', args=['status', '--key', 'default'], obj=obj)
+ assert status.args.subcommand == 'status'
+ assert status.args.key == 'default'
+
+ logout = AuthCommand(command='auth', args=['logout'], obj=obj)
+ assert logout.args.subcommand == 'logout'
+ assert logout.args.key == 'default'
+
+ def test_creds_add_remove_list_parsers(self, tmp_path):
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=CredStore())
+ creds_file = write_json(tmp_path / 'client_secret.json', OAUTH_CLIENT)
+
+ added = CredsCommand(command='creds', args=['add', 'default', str(creds_file)], obj=obj)
+ assert added.args.subcommand == 'add'
+ assert added.args.key == 'default'
+
+ removed = CredsCommand(command='creds', args=['remove', 'default'], obj=obj)
+ assert removed.args.subcommand == 'remove'
+
+ listed = CredsCommand(command='creds', args=['list'], obj=obj)
+ assert listed.args.subcommand == 'list'
+
+ def test_scopes_add_remove_list_parsers(self):
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=CredStore())
+ added = ScopesCommand(command='scopes', args=['add', '--scope', 'admin.directory.user'], obj=obj)
+ assert added.args.subcommand == 'add'
+
+ listed = ScopesCommand(command='scopes', args=['list'], obj=obj)
+ assert listed.args.subcommand == 'list'
+
+ def test_creds_add_execute_stores_default_key(self, tmp_path):
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=CredStore())
+ creds_file = write_json(tmp_path / 'client_secret.json', OAUTH_CLIENT)
+ command = CredsCommand(command='creds', args=['add', 'default', str(creds_file)], obj=obj)
+ command.execute()
+ assert obj.cred_store.get('default', validate_type='oauth').client_id == (
+ 'oauth-client-id.apps.googleusercontent.com'
+ )
+
+
+class TestAuthStatusAndLogout:
+ def test_status_omits_secrets(self, caplog, oauth_creds):
+ store = CredStore()
+ store.add('default', creds=oauth_creds, session=SimpleNamespace(expired=False, client_id='session-client'))
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=store)
+
+ with caplog.at_level(logging.INFO):
+ AuthCommand(command='auth', args=['status'], obj=obj).execute()
+
+ assert 'TEST-NOT-A-SECRET' not in caplog.text
+ assert 'default: type=oauth session=active identity=oauth-client-id.apps.googleusercontent.com' in caplog.text
+
+ def test_logout_clears_session_and_keeps_creds(self, oauth_creds):
+ store = CredStore()
+ store.add(
+ 'default',
+ creds=oauth_creds,
+ session=SimpleNamespace(expired=False, refresh_token=None, client_id='session-client'),
+ )
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=store)
+
+ AuthCommand(command='auth', args=['logout', 'default'], obj=obj).execute()
+
+ assert obj.cred_store.store['default'].session is None
+ assert obj.cred_store.get('default', validate_type='oauth').client_id == oauth_creds.client_id
+
+
+class TestDomainWideDelegation:
+ def test_subject_requires_service_account(self, caplog, oauth_creds):
+ store = CredStore()
+ store.add('default', creds=oauth_creds)
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=store)
+
+ with caplog.at_level(logging.INFO):
+ session = AuthCommand(
+ command='auth',
+ args=['session', '--key', 'default', '--subject', 'user@example.com'],
+ obj=obj,
+ ).authenticate()
+
+ assert session is None
+ assert 'requires --service-account' in caplog.text
+
+ def test_service_account_session_applies_subject(self, service_creds):
+ store = CredStore()
+ store.add('default', creds=service_creds)
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=store)
+ delegated = MagicMock(name='delegated')
+ session = MagicMock(name='session')
+ session.with_subject.return_value = delegated
+
+ with patch('swat.commands.auth.Credentials.from_service_account_info', return_value=session):
+ result = AuthCommand(
+ command='auth',
+ args=[
+ 'session',
+ '--key', 'default',
+ '--service-account',
+ '--subject', 'user@example.com',
+ '--store-key', 'default',
+ ],
+ obj=obj,
+ ).authenticate()
+
+ session.with_subject.assert_called_once_with('user@example.com')
+ assert result is delegated
+ assert obj.cred_store.store['default'].session is delegated
+
+
+class TestShellDiscovery:
+ def test_shell_registers_auth_creds_and_scopes(self):
+ commands = SWATShell.get_commands()
+ assert 'auth' in commands
+ assert 'creds' in commands
+ assert 'scopes' in commands
+ assert 'emulate' in commands
+ assert 'audit' in commands
+ assert 'coverage' in commands
diff --git a/tests/test_public_compat.py b/tests/test_public_compat.py
new file mode 100644
index 0000000..be34718
--- /dev/null
+++ b/tests/test_public_compat.py
@@ -0,0 +1,86 @@
+from swat.base import DEFAULT_CRED_STORE_FILE, CredStore, SWAT
+from swat.commands.coverage import Command as CoverageCommand
+from swat.commands.emulate import Command as EmulateCommand
+from swat.emulations.base_emulation import BaseEmulation
+from swat.utils import load_etc_file
+
+
+KNOWN_COMMANDS = {'audit', 'auth', 'coverage', 'creds', 'emulate', 'scopes'}
+
+KNOWN_EMULATIONS = {
+ 'admin_add_admin_roles_to_users',
+ 'admin_add_creds_to_users',
+ 'admin_disable_2sv_for_user',
+ 'drive_access_private_keys',
+ 'gmail_html_with_embedded_js',
+ 'gmail_phishing_form_link',
+}
+
+
+class TestPublicImports:
+ def test_runtime_entry_points_remain_importable(self):
+ from swat.base import Cred, CredStore, OAuthCreds, ServiceAccountCreds
+ from swat.commands.auth import Command as AuthCommand
+ from swat.commands.base_command import BaseCommand
+ from swat.commands.creds import Command as CredsCommand
+ from swat.main import main
+ from swat.shell import SWATShell
+
+ assert CredStore is not None
+ assert Cred is not None
+ assert OAuthCreds is not None
+ assert ServiceAccountCreds is not None
+ assert BaseCommand is not None
+ assert BaseEmulation is not None
+ assert AuthCommand is not None
+ assert CredsCommand is not None
+ assert callable(main)
+ assert SWATShell is not None
+
+ def test_console_script_still_points_at_main(self):
+ from pathlib import Path
+
+ pyproject = Path('pyproject.toml').read_text()
+ assert "swat = 'swat.main:main'" in pyproject
+
+
+class TestCommandSurface:
+ def test_shell_still_discovers_existing_commands(self):
+ from swat.shell import SWATShell
+
+ assert KNOWN_COMMANDS.issubset(set(SWATShell.get_commands()))
+
+ def test_coverage_parser_keeps_existing_subcommands(self):
+ obj = SWAT(config={'google': {'scopes': []}}, cred_store=CredStore())
+ refresh = CoverageCommand(command='coverage', args=['refresh'], obj=obj)
+ version = CoverageCommand(command='coverage', args=['version'], obj=obj)
+ view = CoverageCommand(command='coverage', args=['view'], obj=obj)
+
+ assert refresh.args.subcommand == 'refresh'
+ assert version.args.subcommand == 'version'
+ assert view.args.subcommand == 'view'
+
+
+class TestEmulationSurface:
+ def test_emulation_module_names_remain(self):
+ assert KNOWN_EMULATIONS.issubset(set(EmulateCommand.get_emulate_commands()))
+
+ def test_emulations_keep_session_key_and_store_access(self):
+ for emulation in EmulateCommand.load_all_emulation_classes():
+ dests = {action.dest for action in emulation.parser._actions}
+ assert 'session_key' in dests, f'{emulation.name} dropped session_key'
+ assert issubclass(emulation, BaseEmulation)
+
+
+class TestConfigContract:
+ def test_default_cred_store_path_stays_repo_local(self):
+ assert DEFAULT_CRED_STORE_FILE.name == '.cred_store.pkl'
+ assert 'swat' in DEFAULT_CRED_STORE_FILE.parts
+ assert 'etc' in DEFAULT_CRED_STORE_FILE.parts
+
+ def test_config_yaml_keeps_operator_settings(self):
+ config = load_etc_file('config.yaml')
+ assert 'google' in config
+ assert 'scopes' in config['google']
+ assert 'domain' in config['google']
+ assert config['settings']['save_on_exit'] is True