From 9598ed9006c3513aa0d4e3012a5f3684d0c2176a Mon Sep 17 00:00:00 2001 From: Cosmin Valentin Balanoiu Date: Thu, 8 Oct 2026 17:27:50 +0300 Subject: [PATCH 1/3] Fix Adobe Sign secure integration key validation Accept the documented secure_integration_key_key setting in the Sign connector schema, matching the existing credential loader. Cover secure and plaintext credentials and clarify the connector example. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- examples/sign/connector-sign.yml | 2 + tests/test_sign_config.py | 104 ++++++++++++++++++++++++++++++- user_sync/config/sign_sync.py | 2 +- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/examples/sign/connector-sign.yml b/examples/sign/connector-sign.yml index d9bf1839..63c49aae 100644 --- a/examples/sign/connector-sign.yml +++ b/examples/sign/connector-sign.yml @@ -15,4 +15,6 @@ deactivate_users: False # Instead of storing API key in plaintext, store in OS credential keychain # See https://adobe-apiplatform.github.io/user-sync.py/en/user-manual/sign_sync.html#securing-the-api-key +# Comment out integration_key above when using secure_integration_key_key. +# Store the credential with admin_email as its username. #secure_integration_key_key: sign_key diff --git a/tests/test_sign_config.py b/tests/test_sign_config.py index 0cc5a62e..88a92719 100644 --- a/tests/test_sign_config.py +++ b/tests/test_sign_config.py @@ -1,9 +1,13 @@ import logging +from types import SimpleNamespace +from unittest.mock import Mock import pytest +from user_sync.config.error import ConfigValidationError from user_sync.config.sign_sync import SignConfigLoader from user_sync.config.user_sync import DictConfig +from user_sync.connector.connector_sign import SignConnector from user_sync.engine.common import AdobeGroup from user_sync.engine.sign import SignSyncEngine from user_sync.error import AssertionException @@ -162,6 +166,105 @@ def test_target_config_options(default_sign_args, modify_sign_config): config.get_target_options() +@pytest.mark.parametrize('org_name', ['primary', 'org2']) +@pytest.mark.parametrize('credential', [ + {'integration_key': 'plaintext-key'}, + {'secure_integration_key_key': 'AdobeSignCreds'}, +]) +def test_target_credentials(default_sign_args, monkeypatch, tmp_path, org_name, credential): + config = SignConfigLoader(default_sign_args) + config.main_config.value['sign_orgs'] = {'primary': 'primary.yml', 'org2': 'org2.yml'} + options = { + 'host': 'api.echosignstage.com', + 'admin_email': 'user@example.com', + 'create_users': False, + 'deactivate_users': False, + **credential, + } + primary_options = {**options, 'integration_key': 'primary-key'} + primary_options.pop('secure_integration_key_key', None) + monkeypatch.setattr(config.config_loader, 'load_sub_config', + lambda path: options if path == f'{org_name}.yml' else primary_options) + keyring_lookup = Mock(return_value='stored-key') + monkeypatch.setattr('keyring.get_password', keyring_lookup) + monkeypatch.setattr('keyring.get_keyring', lambda: SimpleNamespace(name='test-keyring')) + sign_client = Mock() + monkeypatch.setattr('user_sync.connector.connector_sign.SignClient', sign_client) + monkeypatch.setattr('user_sync.connector.connector_sign.SignCache', Mock()) + + target_options = config.get_target_options() + assert target_options[org_name] == options + connection = Mock() + SignConnector(target_options[org_name], org_name, True, connection, {'path': str(tmp_path)}) + + if 'secure_integration_key_key' in credential: + keyring_lookup.assert_called_once_with(service_name='AdobeSignCreds', username='user@example.com') + expected_key = 'stored-key' + else: + keyring_lookup.assert_not_called() + expected_key = 'plaintext-key' + sign_client.assert_called_once_with( + connection, host=options['host'], integration_key=expected_key, + admin_email=options['admin_email'], logger=logging.getLogger(f'sign_{org_name}')) + + +@pytest.mark.parametrize('credential', [ + {}, + {'secure_integration_key': 'AdobeSignCreds'}, + {'secure_integration_key_key': 123}, +]) +def test_invalid_target_credentials(default_sign_args, monkeypatch, credential): + config = SignConfigLoader(default_sign_args) + options = {'host': 'api.echosignstage.com', 'admin_email': 'user@example.com', **credential} + monkeypatch.setattr(config.config_loader, 'load_sub_config', lambda path: options) + with pytest.raises(ConfigValidationError): + config.get_target_options() + + +@pytest.mark.parametrize('stored_key', [None, '']) +def test_missing_secure_target_credential(default_sign_args, monkeypatch, tmp_path, stored_key): + config = SignConfigLoader(default_sign_args) + options = { + 'host': 'api.echosignstage.com', + 'admin_email': 'user@example.com', + 'secure_integration_key_key': 'AdobeSignCreds', + 'create_users': False, + 'deactivate_users': False, + } + monkeypatch.setattr(config.config_loader, 'load_sub_config', lambda path: options) + keyring_lookup = Mock(return_value=stored_key) + monkeypatch.setattr(DictConfig, 'get_value_from_keyring', keyring_lookup) + sign_client = Mock() + monkeypatch.setattr('user_sync.connector.connector_sign.SignClient', sign_client) + with pytest.raises(AssertionException, match='No value in secure storage'): + SignConnector(config.get_target_options()['primary'], 'primary', True, + Mock(), {'path': str(tmp_path)}) + keyring_lookup.assert_called_once_with('AdobeSignCreds', 'user@example.com') + sign_client.assert_not_called() + + +def test_conflicting_target_credentials(default_sign_args, monkeypatch, tmp_path): + config = SignConfigLoader(default_sign_args) + options = { + 'host': 'api.echosignstage.com', + 'admin_email': 'user@example.com', + 'integration_key': 'plaintext-key', + 'secure_integration_key_key': 'AdobeSignCreds', + 'create_users': False, + 'deactivate_users': False, + } + monkeypatch.setattr(config.config_loader, 'load_sub_config', lambda path: options) + keyring_lookup = Mock() + monkeypatch.setattr(DictConfig, 'get_value_from_keyring', keyring_lookup) + sign_client = Mock() + monkeypatch.setattr('user_sync.connector.connector_sign.SignClient', sign_client) + with pytest.raises(AssertionException, match='cannot contain setting for both'): + SignConnector(config.get_target_options()['primary'], 'primary', True, + Mock(), {'path': str(tmp_path)}) + keyring_lookup.assert_not_called() + sign_client.assert_not_called() + + def test_logging_config(default_sign_args): config = SignConfigLoader(default_sign_args) logging_config = config.get_logging_config() @@ -214,4 +317,3 @@ def test_load_primary_group_rules_umg_true_empty(modify_sign_config): } ] assert result == expected_result - diff --git a/user_sync/config/sign_sync.py b/user_sync/config/sign_sync.py index e633e5bd..27f97d9a 100644 --- a/user_sync/config/sign_sync.py +++ b/user_sync/config/sign_sync.py @@ -66,7 +66,7 @@ def connector_schema() -> Schema: from schema import And, Optional, Or, Regex return Schema({ 'host': str, - Or('integration_key', 'secure_integration_key'): str, + Or('integration_key', 'secure_integration_key_key'): str, 'admin_email': str, Optional('create_users'): Optional(bool), Optional('deactivate_users'): Optional(bool), From e75968808dddc502ff7d7595ad3b8d15e1ebf944 Mon Sep 17 00:00:00 2001 From: Cosmin Valentin Balanoiu Date: Thu, 8 Oct 2026 17:54:27 +0300 Subject: [PATCH 2/3] Keep Sign credential regression coverage focused on validation Preserve expanded connector tests on the adobe-sign-expanded-tests fork branch and retain only the two credential schema cases in the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_sign_config.py | 96 ++------------------------------------- 1 file changed, 3 insertions(+), 93 deletions(-) diff --git a/tests/test_sign_config.py b/tests/test_sign_config.py index 88a92719..108da706 100644 --- a/tests/test_sign_config.py +++ b/tests/test_sign_config.py @@ -1,13 +1,9 @@ import logging -from types import SimpleNamespace -from unittest.mock import Mock import pytest -from user_sync.config.error import ConfigValidationError -from user_sync.config.sign_sync import SignConfigLoader +from user_sync.config.sign_sync import SignConfigLoader, connector_schema from user_sync.config.user_sync import DictConfig -from user_sync.connector.connector_sign import SignConnector from user_sync.engine.common import AdobeGroup from user_sync.engine.sign import SignSyncEngine from user_sync.error import AssertionException @@ -166,103 +162,17 @@ def test_target_config_options(default_sign_args, modify_sign_config): config.get_target_options() -@pytest.mark.parametrize('org_name', ['primary', 'org2']) @pytest.mark.parametrize('credential', [ {'integration_key': 'plaintext-key'}, {'secure_integration_key_key': 'AdobeSignCreds'}, ]) -def test_target_credentials(default_sign_args, monkeypatch, tmp_path, org_name, credential): - config = SignConfigLoader(default_sign_args) - config.main_config.value['sign_orgs'] = {'primary': 'primary.yml', 'org2': 'org2.yml'} +def test_connector_schema_credentials(credential): options = { 'host': 'api.echosignstage.com', 'admin_email': 'user@example.com', - 'create_users': False, - 'deactivate_users': False, **credential, } - primary_options = {**options, 'integration_key': 'primary-key'} - primary_options.pop('secure_integration_key_key', None) - monkeypatch.setattr(config.config_loader, 'load_sub_config', - lambda path: options if path == f'{org_name}.yml' else primary_options) - keyring_lookup = Mock(return_value='stored-key') - monkeypatch.setattr('keyring.get_password', keyring_lookup) - monkeypatch.setattr('keyring.get_keyring', lambda: SimpleNamespace(name='test-keyring')) - sign_client = Mock() - monkeypatch.setattr('user_sync.connector.connector_sign.SignClient', sign_client) - monkeypatch.setattr('user_sync.connector.connector_sign.SignCache', Mock()) - - target_options = config.get_target_options() - assert target_options[org_name] == options - connection = Mock() - SignConnector(target_options[org_name], org_name, True, connection, {'path': str(tmp_path)}) - - if 'secure_integration_key_key' in credential: - keyring_lookup.assert_called_once_with(service_name='AdobeSignCreds', username='user@example.com') - expected_key = 'stored-key' - else: - keyring_lookup.assert_not_called() - expected_key = 'plaintext-key' - sign_client.assert_called_once_with( - connection, host=options['host'], integration_key=expected_key, - admin_email=options['admin_email'], logger=logging.getLogger(f'sign_{org_name}')) - - -@pytest.mark.parametrize('credential', [ - {}, - {'secure_integration_key': 'AdobeSignCreds'}, - {'secure_integration_key_key': 123}, -]) -def test_invalid_target_credentials(default_sign_args, monkeypatch, credential): - config = SignConfigLoader(default_sign_args) - options = {'host': 'api.echosignstage.com', 'admin_email': 'user@example.com', **credential} - monkeypatch.setattr(config.config_loader, 'load_sub_config', lambda path: options) - with pytest.raises(ConfigValidationError): - config.get_target_options() - - -@pytest.mark.parametrize('stored_key', [None, '']) -def test_missing_secure_target_credential(default_sign_args, monkeypatch, tmp_path, stored_key): - config = SignConfigLoader(default_sign_args) - options = { - 'host': 'api.echosignstage.com', - 'admin_email': 'user@example.com', - 'secure_integration_key_key': 'AdobeSignCreds', - 'create_users': False, - 'deactivate_users': False, - } - monkeypatch.setattr(config.config_loader, 'load_sub_config', lambda path: options) - keyring_lookup = Mock(return_value=stored_key) - monkeypatch.setattr(DictConfig, 'get_value_from_keyring', keyring_lookup) - sign_client = Mock() - monkeypatch.setattr('user_sync.connector.connector_sign.SignClient', sign_client) - with pytest.raises(AssertionException, match='No value in secure storage'): - SignConnector(config.get_target_options()['primary'], 'primary', True, - Mock(), {'path': str(tmp_path)}) - keyring_lookup.assert_called_once_with('AdobeSignCreds', 'user@example.com') - sign_client.assert_not_called() - - -def test_conflicting_target_credentials(default_sign_args, monkeypatch, tmp_path): - config = SignConfigLoader(default_sign_args) - options = { - 'host': 'api.echosignstage.com', - 'admin_email': 'user@example.com', - 'integration_key': 'plaintext-key', - 'secure_integration_key_key': 'AdobeSignCreds', - 'create_users': False, - 'deactivate_users': False, - } - monkeypatch.setattr(config.config_loader, 'load_sub_config', lambda path: options) - keyring_lookup = Mock() - monkeypatch.setattr(DictConfig, 'get_value_from_keyring', keyring_lookup) - sign_client = Mock() - monkeypatch.setattr('user_sync.connector.connector_sign.SignClient', sign_client) - with pytest.raises(AssertionException, match='cannot contain setting for both'): - SignConnector(config.get_target_options()['primary'], 'primary', True, - Mock(), {'path': str(tmp_path)}) - keyring_lookup.assert_not_called() - sign_client.assert_not_called() + assert connector_schema().validate(options) == options def test_logging_config(default_sign_args): From 03e6cd29093e0937d4cb71b59d024480e54275f7 Mon Sep 17 00:00:00 2001 From: Cosmin Valentin Balanoiu Date: Thu, 8 Oct 2026 17:58:16 +0300 Subject: [PATCH 3/3] Preserve existing test file trailing whitespace Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_sign_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_sign_config.py b/tests/test_sign_config.py index 108da706..c5ee606a 100644 --- a/tests/test_sign_config.py +++ b/tests/test_sign_config.py @@ -227,3 +227,4 @@ def test_load_primary_group_rules_umg_true_empty(modify_sign_config): } ] assert result == expected_result +