Skip to content

Commit 80fe32f

Browse files
committed
Add credential refresh lifecycle tests (#3781)
1 parent 9c36a0f commit 80fe32f

2 files changed

Lines changed: 733 additions & 0 deletions

File tree

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
[
2+
{
3+
"documentation": "Valid cached credentials: no refresh is attempted and the caller receives the cached credentials.",
4+
"given": { "cachedCredentials": "valid" },
5+
"steps": [
6+
{
7+
"type": "getCredentials",
8+
"expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": false }
9+
}
10+
]
11+
},
12+
{
13+
"documentation": "Advisory window, refresh succeeds: the caller receives the newly refreshed credentials.",
14+
"given": { "cachedCredentials": "advisory" },
15+
"steps": [
16+
{
17+
"type": "getCredentials",
18+
"response": "freshCredentials",
19+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
20+
}
21+
]
22+
},
23+
{
24+
"documentation": "Advisory window, refresh fails: the resolver applies the refresh backoff and the caller receives the existing cached credentials.",
25+
"given": { "cachedCredentials": "advisory" },
26+
"steps": [
27+
{
28+
"type": "getCredentials",
29+
"response": "error",
30+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
31+
}
32+
]
33+
},
34+
{
35+
"documentation": "Mandatory window, refresh succeeds: the caller receives the newly refreshed credentials.",
36+
"given": { "cachedCredentials": "mandatory" },
37+
"steps": [
38+
{
39+
"type": "getCredentials",
40+
"response": "freshCredentials",
41+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
42+
}
43+
]
44+
},
45+
{
46+
"documentation": "Mandatory window, refresh fails: the resolver applies the refresh backoff and the caller receives the cached credentials.",
47+
"given": { "cachedCredentials": "mandatory" },
48+
"steps": [
49+
{
50+
"type": "getCredentials",
51+
"response": "error",
52+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
53+
}
54+
]
55+
},
56+
{
57+
"documentation": "Expired credentials are refreshed successfully: the caller receives the newly refreshed credentials.",
58+
"given": { "cachedCredentials": "expired" },
59+
"steps": [
60+
{
61+
"type": "getCredentials",
62+
"response": "freshCredentials",
63+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
64+
}
65+
]
66+
},
67+
{
68+
"documentation": "Expired credentials, refresh fails: the resolver applies the refresh backoff and the caller receives the expired cached credentials rather than raising.",
69+
"given": { "cachedCredentials": "expired" },
70+
"steps": [
71+
{
72+
"type": "getCredentials",
73+
"response": "error",
74+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
75+
}
76+
]
77+
},
78+
{
79+
"documentation": "No cached credentials and the initial fetch fails: the SDK raises, since there are no cached credentials to fall back on. The next call retries and succeeds.",
80+
"given": { "cachedCredentials": "none" },
81+
"steps": [
82+
{
83+
"type": "getCredentials",
84+
"response": "error",
85+
"expected": { "result": "noCredentialsError", "sourceContacted": true, "rateLimited": false }
86+
},
87+
{
88+
"type": "getCredentials",
89+
"response": "freshCredentials",
90+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
91+
}
92+
]
93+
},
94+
{
95+
"documentation": "Advisory window, source returns stale credentials (Expiration at or before now): treated as a failed refresh. The resolver applies the refresh backoff and returns the existing cached credentials.",
96+
"given": { "cachedCredentials": "advisory" },
97+
"steps": [
98+
{
99+
"type": "getCredentials",
100+
"response": "staleCredentials",
101+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
102+
}
103+
]
104+
},
105+
{
106+
"documentation": "Mandatory window, source returns stale credentials: same as the advisory case, treated as a failed refresh.",
107+
"given": { "cachedCredentials": "mandatory" },
108+
"steps": [
109+
{
110+
"type": "getCredentials",
111+
"response": "staleCredentials",
112+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
113+
}
114+
]
115+
},
116+
117+
{
118+
"documentation": "A 10-minute credential lifetime selects the 5-minute advisory window (lifetime <= 20 minutes).",
119+
"given": { "cachedCredentials": "none" },
120+
"steps": [
121+
{
122+
"type": "getCredentials",
123+
"response": "freshCredentials",
124+
"lifetimeSeconds": 600,
125+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 300 }
126+
}
127+
]
128+
},
129+
{
130+
"documentation": "A 20.5-minute credential lifetime selects the 15-minute advisory window (lifetime > 20 and < 90 minutes).",
131+
"given": { "cachedCredentials": "none" },
132+
"steps": [
133+
{
134+
"type": "getCredentials",
135+
"response": "freshCredentials",
136+
"lifetimeSeconds": 1230,
137+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 900 }
138+
}
139+
]
140+
},
141+
{
142+
"documentation": "A 6-hour credential lifetime selects the 60-minute advisory window (lifetime >= 90 minutes).",
143+
"given": { "cachedCredentials": "none" },
144+
"steps": [
145+
{
146+
"type": "getCredentials",
147+
"response": "freshCredentials",
148+
"lifetimeSeconds": 21600,
149+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 3600 }
150+
}
151+
]
152+
},
153+
{
154+
"documentation": "After a successful refresh returns credentials with a different lifetime, the SDK recomputes the advisory window. The first credentials have a 6-hour lifetime (60-minute window); after advancing into that window, the refreshed credentials have a 10-minute lifetime (5-minute window).",
155+
"given": { "cachedCredentials": "none" },
156+
"steps": [
157+
{
158+
"type": "getCredentials",
159+
"response": "freshCredentials",
160+
"lifetimeSeconds": 21600,
161+
"documentation": "Initial fetch returns 6-hour credentials, selecting the 60-minute advisory window.",
162+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 3600 }
163+
},
164+
{
165+
"type": "advanceTime",
166+
"seconds": 18060
167+
},
168+
{
169+
"type": "getCredentials",
170+
"response": "freshCredentials",
171+
"lifetimeSeconds": 600,
172+
"documentation": "59 minutes remain until expiration, inside the 60-minute advisory window, so the SDK refreshes. The new 10-minute credentials select the 5-minute advisory window.",
173+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 300 }
174+
}
175+
]
176+
},
177+
{
178+
"documentation": "A customer-configured advisory window overrides the table. Credentials with a 6-hour lifetime would map to 60 minutes, but the configured 30-minute window is used instead.",
179+
"given": { "cachedCredentials": "none", "configuredAdvisoryWindowSeconds": 1800 },
180+
"steps": [
181+
{
182+
"type": "getCredentials",
183+
"response": "freshCredentials",
184+
"lifetimeSeconds": 21600,
185+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 1800 }
186+
}
187+
]
188+
},
189+
190+
{
191+
"documentation": "Advisory window, non-recoverable failure: the SDK raises immediately. No refresh backoff is applied, but the error is cached for up to 5 seconds, so a recovering call succeeds once that cache expires.",
192+
"given": { "cachedCredentials": "advisory" },
193+
"steps": [
194+
{
195+
"type": "getCredentials",
196+
"response": "nonRecoverableError",
197+
"documentation": "Non-recoverable failure: the SDK raises and does not apply the refresh backoff.",
198+
"expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false }
199+
},
200+
{
201+
"type": "advanceTime",
202+
"seconds": 6
203+
},
204+
{
205+
"type": "getCredentials",
206+
"response": "freshCredentials",
207+
"documentation": "The non-recoverable error cache (max 5 seconds) has expired, so this call contacts the source again and succeeds.",
208+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
209+
}
210+
]
211+
},
212+
{
213+
"documentation": "Mandatory window, non-recoverable failure: the SDK raises immediately. No refresh backoff is applied, but the error is cached for up to 5 seconds, so a recovering call succeeds once that cache expires.",
214+
"given": { "cachedCredentials": "mandatory" },
215+
"steps": [
216+
{
217+
"type": "getCredentials",
218+
"response": "nonRecoverableError",
219+
"documentation": "Non-recoverable failure: the SDK raises and does not apply the refresh backoff.",
220+
"expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false }
221+
},
222+
{
223+
"type": "advanceTime",
224+
"seconds": 6
225+
},
226+
{
227+
"type": "getCredentials",
228+
"response": "freshCredentials",
229+
"documentation": "The non-recoverable error cache (max 5 seconds) has expired, so this call contacts the source again and succeeds.",
230+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
231+
}
232+
]
233+
},
234+
{
235+
"documentation": "Non-recoverable error, then an immediate retry with no clock advance: the error is still cached, so the SDK re-raises it without contacting the source. This protects the credential source from an application that swallows the error and retries in a loop.",
236+
"given": { "cachedCredentials": "advisory" },
237+
"steps": [
238+
{
239+
"type": "getCredentials",
240+
"response": "nonRecoverableError",
241+
"documentation": "Non-recoverable failure: the SDK raises and caches the error for up to 5 seconds.",
242+
"expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false }
243+
},
244+
{
245+
"type": "getCredentials",
246+
"documentation": "Immediate retry with no clock advance. The cached error is still active, so the SDK re-raises it without contacting the source.",
247+
"expected": { "result": "nonRecoverableError", "sourceContacted": false, "rateLimited": false }
248+
}
249+
]
250+
},
251+
252+
{
253+
"documentation": "Invalidate with an access key ID matching the cached credentials routes the next getCredentials through the mandatory refresh path, and the refresh succeeds.",
254+
"given": { "cachedCredentials": "valid", "accessKeyId": "AKID-1" },
255+
"steps": [
256+
{ "type": "invalidate", "rejectedAccessKeyId": "AKID-1" },
257+
{
258+
"type": "getCredentials",
259+
"response": "freshCredentials",
260+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
261+
}
262+
]
263+
},
264+
{
265+
"documentation": "Invalidate with a matching access key ID routes the next getCredentials through the mandatory refresh path; the refresh fails and the SDK continues using the cached credentials.",
266+
"given": { "cachedCredentials": "valid", "accessKeyId": "AKID-1" },
267+
"steps": [
268+
{ "type": "invalidate", "rejectedAccessKeyId": "AKID-1" },
269+
{
270+
"type": "getCredentials",
271+
"response": "error",
272+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
273+
}
274+
]
275+
},
276+
{
277+
"documentation": "Invalidate during an active backoff: the SDK does not contact the credential source. Once the refresh backoff has elapsed, the next getCredentials attempts a refresh.",
278+
"given": { "cachedCredentials": "expired", "accessKeyId": "AKID-1", "refreshBackoffSeconds": 420 },
279+
"steps": [
280+
{
281+
"type": "getCredentials",
282+
"response": "error",
283+
"documentation": "Refresh fails, so the SDK applies the refresh backoff.",
284+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
285+
},
286+
{
287+
"type": "advanceTime",
288+
"seconds": 60
289+
},
290+
{ "type": "invalidate", "rejectedAccessKeyId": "AKID-1" },
291+
{
292+
"type": "getCredentials",
293+
"documentation": "60s elapsed and the refresh backoff has not yet elapsed, so even after invalidation the SDK does not contact the credential source.",
294+
"expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": true }
295+
},
296+
{
297+
"type": "advanceTime",
298+
"seconds": 425
299+
},
300+
{
301+
"type": "getCredentials",
302+
"response": "freshCredentials",
303+
"documentation": "485s elapsed total and the refresh backoff has elapsed, so the SDK contacts the credential source and the refresh succeeds.",
304+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
305+
}
306+
]
307+
},
308+
{
309+
"documentation": "Invalidate with a stale access key ID (a concurrent refresh already replaced the credentials): the cache is unchanged and the next getCredentials does not contact the source.",
310+
"given": { "cachedCredentials": "valid", "accessKeyId": "AKID-2" },
311+
"steps": [
312+
{ "type": "invalidate", "rejectedAccessKeyId": "AKID-1" },
313+
{
314+
"type": "getCredentials",
315+
"expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": false }
316+
}
317+
]
318+
},
319+
320+
{
321+
"documentation": "After a failed refresh, the SDK does not contact the credential source again until the refresh backoff has elapsed.",
322+
"given": { "cachedCredentials": "expired", "refreshBackoffSeconds": 420 },
323+
"steps": [
324+
{
325+
"type": "getCredentials",
326+
"response": "error",
327+
"documentation": "Refresh fails, so the SDK applies the refresh backoff.",
328+
"expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false }
329+
},
330+
{
331+
"type": "advanceTime",
332+
"seconds": 300
333+
},
334+
{
335+
"type": "getCredentials",
336+
"documentation": "300s elapsed and the refresh backoff has not yet elapsed, so the SDK does not contact the credential source.",
337+
"expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": true }
338+
},
339+
{
340+
"type": "advanceTime",
341+
"seconds": 425
342+
},
343+
{
344+
"type": "getCredentials",
345+
"response": "freshCredentials",
346+
"documentation": "725s elapsed total and the refresh backoff has elapsed, so the SDK contacts the credential source and the refresh succeeds.",
347+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
348+
}
349+
]
350+
},
351+
{
352+
"documentation": "No cached credentials and the initial fetch fails with a non-recoverable error: the SDK raises the error directly rather than a generic NoCredentialsError. No refresh backoff is applied, but the error is cached for up to 5 seconds, so a recovering call succeeds once that cache expires.",
353+
"given": { "cachedCredentials": "none" },
354+
"steps": [
355+
{
356+
"type": "getCredentials",
357+
"response": "nonRecoverableError",
358+
"documentation": "Non-recoverable failure: the SDK raises and does not apply the refresh backoff.",
359+
"expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false }
360+
},
361+
{
362+
"type": "advanceTime",
363+
"seconds": 6
364+
},
365+
{
366+
"type": "getCredentials",
367+
"response": "freshCredentials",
368+
"documentation": "The non-recoverable error cache (max 5 seconds) has expired, so this call contacts the source again and succeeds.",
369+
"expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false }
370+
}
371+
]
372+
}
373+
]

0 commit comments

Comments
 (0)