88from unittest .mock import AsyncMock , Mock , patch
99
1010import pytest
11- from smithy_aws_core .identity .components import (
12- AWSCredentialsIdentity ,
13- AWSIdentityProperties ,
14- )
1511from smithy_aws_core .identity .process import (
1612 ProcessCredentialsConfig ,
1713 ProcessCredentialsResolver ,
1814)
19- from smithy_core .aio .identity import ChainedIdentityResolver
2015from smithy_core .exceptions import SmithyIdentityError
2116
2217ISO8601 = "%Y-%m-%dT%H:%M:%SZ"
@@ -39,19 +34,10 @@ def test_config_custom_values():
3934 assert config .timeout == 60
4035
4136
42- def test_resolver_empty_command ():
43- with pytest .raises (ValueError , match = "command must be a non-empty string or list" ):
44- ProcessCredentialsResolver ([])
45-
46-
47- def test_resolver_none_command ():
48- with pytest .raises (ValueError , match = "command must be a non-empty string or list" ):
49- ProcessCredentialsResolver (None ) # type: ignore[arg-type]
50-
51-
52- def test_resolver_empty_command_string ():
37+ @pytest .mark .parametrize ("command" , [[], "" , None ])
38+ def test_resolver_invalid_command (command : object ):
5339 with pytest .raises (ValueError , match = "command must be a non-empty string or list" ):
54- ProcessCredentialsResolver ("" )
40+ ProcessCredentialsResolver (command ) # type: ignore[arg-type]
5541
5642
5743def mock_subprocess (returncode : int , stdout : bytes , stderr : bytes = b"" ):
@@ -97,6 +83,41 @@ async def test_valid_credentials_without_session_token():
9783 assert identity .session_token is None
9884
9985
86+ @pytest .mark .asyncio
87+ async def test_missing_expiration ():
88+ resp_body = json .dumps (DEFAULT_RESPONSE_DATA )
89+ process = mock_subprocess (0 , resp_body .encode ("utf-8" ))
90+
91+ with patch ("asyncio.create_subprocess_exec" , return_value = process ):
92+ resolver = ProcessCredentialsResolver (["mock-process" ])
93+ identity = await resolver .get_identity (properties = {})
94+
95+ assert identity .access_key_id == "foo"
96+ assert identity .secret_access_key == "bar"
97+ assert identity .session_token == "baz"
98+ assert identity .expiration is None
99+
100+
101+ @pytest .mark .asyncio
102+ async def test_missing_expiration_and_session_token ():
103+ resp_data = {
104+ "Version" : 1 ,
105+ "AccessKeyId" : "foo" ,
106+ "SecretAccessKey" : "bar" ,
107+ }
108+ resp_body = json .dumps (resp_data )
109+ process = mock_subprocess (0 , resp_body .encode ("utf-8" ))
110+
111+ with patch ("asyncio.create_subprocess_exec" , return_value = process ):
112+ resolver = ProcessCredentialsResolver (["mock-process" ])
113+ identity = await resolver .get_identity (properties = {})
114+
115+ assert identity .access_key_id == "foo"
116+ assert identity .secret_access_key == "bar"
117+ assert identity .session_token is None
118+ assert identity .expiration is None
119+
120+
100121@pytest .mark .asyncio
101122async def test_credentials_with_expiration ():
102123 current_time = datetime .now (UTC ) + timedelta (minutes = 10 )
@@ -114,6 +135,25 @@ async def test_credentials_with_expiration():
114135 assert identity .expiration .tzinfo == UTC
115136
116137
138+ @pytest .mark .asyncio
139+ async def test_credentials_with_non_utc_expiration ():
140+ """Test that non-UTC expiration timestamps are correctly converted to UTC."""
141+ # 2026-03-16T10:00:00+05:00 should become 2026-03-16T05:00:00 UTC
142+ resp_data = dict (DEFAULT_RESPONSE_DATA )
143+ resp_data ["Expiration" ] = "2026-03-16T10:00:00+05:00"
144+
145+ resp_body = json .dumps (resp_data )
146+ process = mock_subprocess (0 , resp_body .encode ("utf-8" ))
147+
148+ with patch ("asyncio.create_subprocess_exec" , return_value = process ):
149+ resolver = ProcessCredentialsResolver (["mock-process" ])
150+ identity = await resolver .get_identity (properties = {})
151+
152+ assert identity .expiration is not None
153+ assert identity .expiration .tzinfo == UTC
154+ assert identity .expiration == datetime (2026 , 3 , 16 , 5 , 0 , 0 , tzinfo = UTC )
155+
156+
117157@pytest .mark .asyncio
118158async def test_credentials_with_account_id ():
119159 resp_data = dict (DEFAULT_RESPONSE_DATA )
@@ -210,7 +250,7 @@ async def test_invalid_json():
210250
211251 with patch ("asyncio.create_subprocess_exec" , return_value = process ):
212252 resolver = ProcessCredentialsResolver (["mock-process" ])
213- with pytest .raises (json . JSONDecodeError ):
253+ with pytest .raises (SmithyIdentityError , match = "Failed to parse" ):
214254 await resolver .get_identity (properties = {})
215255
216256
@@ -244,33 +284,6 @@ async def test_process_startup_failure_raises_smithy_identity_error():
244284 await resolver .get_identity (properties = {})
245285
246286
247- @pytest .mark .asyncio
248- async def test_process_startup_failure_allows_chained_fallback ():
249- class SuccessfulResolver :
250- async def get_identity (
251- self , * , properties : AWSIdentityProperties
252- ) -> AWSCredentialsIdentity :
253- return AWSCredentialsIdentity (
254- access_key_id = "fallback-akid" ,
255- secret_access_key = "fallback-secret" ,
256- )
257-
258- with patch (
259- "asyncio.create_subprocess_exec" ,
260- side_effect = FileNotFoundError ("No such file or directory" ),
261- ):
262- resolver = ChainedIdentityResolver (
263- [
264- ProcessCredentialsResolver (["missing-process" ]),
265- SuccessfulResolver (),
266- ]
267- )
268- identity = await resolver .get_identity (properties = {})
269-
270- assert identity .access_key_id == "fallback-akid"
271- assert identity .secret_access_key == "fallback-secret"
272-
273-
274287@pytest .mark .asyncio
275288async def test_long_term_credentials_cached ():
276289 """Test that credentials without expiration are cached indefinitely."""
@@ -313,13 +326,25 @@ async def test_temporary_credentials_cached_when_valid():
313326async def test_expired_credentials_refreshed ():
314327 """Test that expired credentials are refreshed."""
315328 expired_time = datetime .now (UTC ) - timedelta (minutes = 10 )
316- resp_data = dict (DEFAULT_RESPONSE_DATA )
317- resp_data ["Expiration" ] = expired_time .strftime (ISO8601 )
329+ initial_data = dict (DEFAULT_RESPONSE_DATA )
330+ initial_data ["Expiration" ] = expired_time .strftime (ISO8601 )
318331
319- resp_body = json .dumps (resp_data )
320- process = mock_subprocess (0 , resp_body .encode ("utf-8" ))
332+ refreshed_time = datetime .now (UTC ) + timedelta (minutes = 10 )
333+ refreshed_data = {
334+ "Version" : 1 ,
335+ "AccessKeyId" : "foo-refreshed" ,
336+ "SecretAccessKey" : "bar-refreshed" ,
337+ "SessionToken" : "baz-refreshed" ,
338+ "Expiration" : refreshed_time .strftime (ISO8601 ),
339+ }
321340
322- with patch ("asyncio.create_subprocess_exec" , return_value = process ) as mock_exec :
341+ first_process = mock_subprocess (0 , json .dumps (initial_data ).encode ("utf-8" ))
342+ second_process = mock_subprocess (0 , json .dumps (refreshed_data ).encode ("utf-8" ))
343+
344+ with patch (
345+ "asyncio.create_subprocess_exec" ,
346+ side_effect = [first_process , second_process ],
347+ ) as mock_exec :
323348 resolver = ProcessCredentialsResolver (["mock-process" ])
324349 identity_one = await resolver .get_identity (properties = {})
325350 identity_two = await resolver .get_identity (properties = {})
@@ -328,9 +353,12 @@ async def test_expired_credentials_refreshed():
328353 assert mock_exec .call_count == 2
329354 # Should be different instances
330355 assert identity_one is not identity_two
331- # But have the same values
332- assert identity_one .access_key_id == identity_two .access_key_id
333- assert identity_one .secret_access_key == identity_two .secret_access_key
356+ assert identity_one .access_key_id == "foo"
357+ assert identity_one .secret_access_key == "bar"
358+ assert identity_one .session_token == "baz"
359+ assert identity_two .access_key_id == "foo-refreshed"
360+ assert identity_two .secret_access_key == "bar-refreshed"
361+ assert identity_two .session_token == "baz-refreshed"
334362
335363
336364@pytest .mark .asyncio
0 commit comments