From cd542b2625deda8f6f43727b2053ebb0ce5db5b4 Mon Sep 17 00:00:00 2001 From: Sonal Sonal Date: Fri, 1 Aug 2025 07:56:10 -0700 Subject: [PATCH 1/3] Add Integ tests --- test/integ/test_cache_hook.py | 89 +++++++++++++++++++++++++++++ test/integ/test_config.py | 102 ++++++++++++++++++++++++++++++++++ test/integ/test_decorators.py | 86 ++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 test/integ/test_cache_hook.py create mode 100644 test/integ/test_config.py create mode 100644 test/integ/test_decorators.py diff --git a/test/integ/test_cache_hook.py b/test/integ/test_cache_hook.py new file mode 100644 index 0000000..061012b --- /dev/null +++ b/test/integ/test_cache_hook.py @@ -0,0 +1,89 @@ +# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +from uuid import uuid4 + +import botocore +import botocore.session +import pytest +from aws_secretsmanager_caching.cache.secret_cache_hook import SecretCacheHook +from aws_secretsmanager_caching.config import SecretCacheConfig +from aws_secretsmanager_caching.secret_cache import SecretCache + + +class TestCacheHook(SecretCacheHook): + """Test implementation of SecretCacheHook for integration testing""" + + def __init__(self): + self.put_calls = 0 + self.get_calls = 0 + + def put(self, obj): + self.put_calls += 1 + # Return modified copy without mutating original + modified_obj = obj.copy() + if 'SecretString' in modified_obj: + modified_obj['SecretString'] = f"HOOKED_{modified_obj['SecretString']}" + if 'SecretBinary' in modified_obj: + modified_obj['SecretBinary'] = b'HOOKED_' + modified_obj['SecretBinary'] + return modified_obj + + def get(self, cached_obj): + self.get_calls += 1 + return cached_obj + + +class TestCacheHookInteg: + fixture_prefix = 'python_hook_integ_test_' + uuid_suffix = uuid4().hex + + @pytest.fixture(scope='module') + def client(self): + yield botocore.session.get_session().create_client('secretsmanager', region_name='us-east-1') + + @pytest.fixture + def secret_string(self, request, client): + name = f"{self.fixture_prefix}{request.function.__name__}{self.uuid_suffix}" + secret = client.create_secret(Name=name, SecretString='test_value') + yield secret + client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) + + @pytest.fixture + def secret_binary(self, request, client): + name = f"{self.fixture_prefix}{request.function.__name__}{self.uuid_suffix}" + secret = client.create_secret(Name=name, SecretBinary=b'binary_data') + yield secret + client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) + + def test_cache_hook_string_secret(self, client, secret_string): + hook = TestCacheHook() + config = SecretCacheConfig(secret_cache_hook=hook) + cache = SecretCache(config=config, client=client) + + # First call should trigger put and get + result = cache.get_secret_string(secret_string['Name']) + print(f"Result: {result}, Put calls: {hook.put_calls}, Get calls: {hook.get_calls}") + assert "test_value" in result # Just check the value is there for now + + # Second call should only trigger get (cached) + result = cache.get_secret_string(secret_string['Name']) + print(f"Second result: {result}, Put calls: {hook.put_calls}, Get calls: {hook.get_calls}") + + def test_cache_hook_binary_secret(self, client, secret_binary): + hook = TestCacheHook() + config = SecretCacheConfig(secret_cache_hook=hook) + cache = SecretCache(config=config, client=client) + + result = cache.get_secret_binary(secret_binary['Name']) + print(f"Binary result: {result}, Put calls: {hook.put_calls}, Get calls: {hook.get_calls}") + assert b'binary_data' in result # Just check the value is there for now diff --git a/test/integ/test_config.py b/test/integ/test_config.py new file mode 100644 index 0000000..a6d54ec --- /dev/null +++ b/test/integ/test_config.py @@ -0,0 +1,102 @@ +# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +import time +from uuid import uuid4 + +import botocore +import botocore.session +import pytest +from aws_secretsmanager_caching.config import SecretCacheConfig +from aws_secretsmanager_caching.secret_cache import SecretCache + + +class TestConfigInteg: + fixture_prefix = 'python_config_integ_test_' + uuid_suffix = uuid4().hex + + @pytest.fixture(scope='module') + def client(self): + yield botocore.session.get_session().create_client('secretsmanager', region_name='us-east-1') + + @pytest.fixture + def secret_with_versions(self, request, client): + name = f"{self.fixture_prefix}{request.function.__name__}{self.uuid_suffix}" + + # Create secret with initial version + secret = client.create_secret(Name=name, SecretString='version1') + + # Add a new version + client.put_secret_value( + SecretId=secret['ARN'], + SecretString='version2', + VersionStages=['AWSCURRENT'] + ) + + yield secret + client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) + + def test_custom_version_stage(self, client, secret_with_versions): + config = SecretCacheConfig(default_version_stage='AWSPREVIOUS') + cache = SecretCache(config=config, client=client) + + result = cache.get_secret_string(secret_with_versions['Name']) + assert result == 'version1' # Should get the previous version + + def test_fast_refresh_interval(self, client, secret_with_versions): + config = SecretCacheConfig(secret_refresh_interval=1) # 1 second refresh + cache = SecretCache(config=config, client=client) + + # Get initial value + result1 = cache.get_secret_string(secret_with_versions['Name']) + assert result1 == 'version2' + + # Update secret + client.put_secret_value( + SecretId=secret_with_versions['ARN'], + SecretString='version3', + VersionStages=['AWSCURRENT'] + ) + + # Wait for refresh interval + time.sleep(2) + + # Should get updated value + result2 = cache.get_secret_string(secret_with_versions['Name']) + assert result2 == 'version3' + + def test_max_cache_size(self, client): + config = SecretCacheConfig(max_cache_size=2) + cache = SecretCache(config=config, client=client) + + # Create multiple secrets to test cache eviction + secrets = [] + for i in range(3): + name = f"{self.fixture_prefix}cache_size_{i}_{self.uuid_suffix}" + secret = client.create_secret(Name=name, SecretString=f'value{i}') + secrets.append(secret) + + try: + # Access secrets to fill cache beyond limit + for i, secret in enumerate(secrets): + result = cache.get_secret_string(secret['Name']) + assert result == f'value{i}' + + # Cache should still work despite size limit + result = cache.get_secret_string(secrets[0]['Name']) + assert result == 'value0' + + finally: + # Cleanup + for secret in secrets: + client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) diff --git a/test/integ/test_decorators.py b/test/integ/test_decorators.py new file mode 100644 index 0000000..e213138 --- /dev/null +++ b/test/integ/test_decorators.py @@ -0,0 +1,86 @@ +# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +import json +from uuid import uuid4 + +import botocore +import botocore.session +import pytest +from aws_secretsmanager_caching.decorators import InjectKeywordedSecretString, InjectSecretString +from aws_secretsmanager_caching.secret_cache import SecretCache + + +class TestDecoratorsInteg: + fixture_prefix = 'python_decorator_integ_test_' + uuid_suffix = uuid4().hex + + @pytest.fixture(scope='module') + def client(self): + yield botocore.session.get_session().create_client('secretsmanager', region_name='us-east-1') + + @pytest.fixture + def json_secret(self, request, client): + name = f"{self.fixture_prefix}{request.function.__name__}{self.uuid_suffix}" + secret_data = {"username": "test_user", "password": "test_pass", "host": "localhost"} + + secret = client.create_secret(Name=name, SecretString=json.dumps(secret_data)) + yield secret, secret_data + client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) + + @pytest.fixture + def string_secret(self, request, client): + name = f"{self.fixture_prefix}{request.function.__name__}{self.uuid_suffix}" + secret_value = "simple_secret_value" + + secret = client.create_secret(Name=name, SecretString=secret_value) + yield secret, secret_value + client.delete_secret(SecretId=secret['ARN'], ForceDeleteWithoutRecovery=True) + + def test_inject_keyworded_secret_string(self, client, json_secret): + secret, secret_data = json_secret + cache = SecretCache(client=client) + + @InjectKeywordedSecretString(secret_id=secret['Name'], cache=cache, + func_username='username', func_password='password') + def test_function(func_username, func_password): + return func_username, func_password + + username, password = test_function() + assert username == secret_data['username'] + assert password == secret_data['password'] + + def test_inject_secret_string(self, client, string_secret): + secret, secret_value = string_secret + cache = SecretCache(client=client) + + @InjectSecretString(secret['Name'], cache) + def test_function(injected_secret, other_arg): + return injected_secret, other_arg + + result_secret, result_arg = test_function("test_arg") + assert result_secret == secret_value + assert result_arg == "test_arg" + + def test_inject_secret_string_class_method(self, client, string_secret): + secret, secret_value = string_secret + cache = SecretCache(client=client) + + class TestClass: + @InjectSecretString(secret['Name'], cache) + def method(self, injected_secret): + return injected_secret + + test_instance = TestClass() + result = test_instance.method() + assert result == secret_value From 25808c9331385dad3792ebaeba0dabfe069429f2 Mon Sep 17 00:00:00 2001 From: Simon Marty Date: Fri, 1 Aug 2025 11:51:12 -0700 Subject: [PATCH 2/3] Ignore integ folder in codecov --- codecov.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f58ce00 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "test/integ" \ No newline at end of file From a1628b59c25a7f93e4dc625a8da66815b37d78f4 Mon Sep 17 00:00:00 2001 From: Simon Marty Date: Fri, 1 Aug 2025 11:57:04 -0700 Subject: [PATCH 3/3] Add codecov token --- .github/workflows/python-package.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 91dde32..30bb0f7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -44,3 +44,5 @@ jobs: pytest test/unit/ - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }}