@@ -26,7 +26,7 @@ public class ConfigurationManagerTests
2626 {
2727 /// <summary>
2828 /// This test reaches out the the internet to fetch the OpenIdConnectConfiguration from the specified metadata address.
29- /// There is no validaiton of the configuration. The validation is done in the OpenIdConnectConfigurationSerializationTests.Deserialize
29+ /// There is no validation of the configuration. The validation is done in the OpenIdConnectConfigurationSerializationTests.Deserialize
3030 /// against values obtained 2/2/2024
3131 /// </summary>
3232 /// <param name="theoryData"></param>
@@ -209,25 +209,120 @@ public async Task FetchMetadataFailureTest()
209209 TestUtilities . AssertFailIfErrors ( context ) ;
210210 }
211211
212+ [ Fact ]
213+ public async Task VerifyInterlockGuardForRequestRefresh ( )
214+ {
215+ ManualResetEvent waitEvent = new ManualResetEvent ( false ) ;
216+ ManualResetEvent signalEvent = new ManualResetEvent ( false ) ;
217+ InMemoryDocumentRetriever inMemoryDocumentRetriever = InMemoryDocumentRetrieverWithEvents ( waitEvent , signalEvent ) ;
218+
219+ var configurationManager = new ConfigurationManager < OpenIdConnectConfiguration > (
220+ "AADCommonV1Json" ,
221+ new OpenIdConnectConfigurationRetriever ( ) ,
222+ inMemoryDocumentRetriever ) ;
223+
224+ // populate the configurationManager with AADCommonV1Config
225+ TestUtilities . SetField ( configurationManager , "_currentConfiguration" , OpenIdConfigData . AADCommonV1Config ) ;
226+
227+ // InMemoryDocumentRetrieverWithEvents will block until waitEvent.Set() is called.
228+ // The first RequestRefresh will not have finished before the next RequestRefresh() is called.
229+ // The guard '_lastRequestRefresh' will not block as we set it to DateTimeOffset.MinValue.
230+ // Interlocked guard will block.
231+ // Configuration should be AADCommonV1Config
232+ signalEvent . Reset ( ) ;
233+ configurationManager . RequestRefresh ( ) ;
234+
235+ // InMemoryDocumentRetrieverWithEvents will signal when it is OK to change the MetadataAddress
236+ // otherwise, it may be the case that the MetadataAddress is changed before the previous Task has finished.
237+ signalEvent . WaitOne ( ) ;
238+
239+ // AADCommonV1Json would have been passed to the the previous retriever, which is blocked on an event.
240+ configurationManager . MetadataAddress = "AADCommonV2Json" ;
241+ TestUtilities . SetField ( configurationManager , "_lastRequestRefresh" , DateTimeOffset . MinValue ) ;
242+ configurationManager . RequestRefresh ( ) ;
243+
244+ // Set the event to release the lock and let the previous retriever finish.
245+ waitEvent . Set ( ) ;
246+
247+ // Configuration should be AADCommonV1Config
248+ var configuration = await configurationManager . GetConfigurationAsync ( ) ;
249+ Assert . True ( configuration . Issuer . Equals ( OpenIdConfigData . AADCommonV1Config . Issuer ) ,
250+ $ "configuration.Issuer from configurationManager was not as expected," +
251+ $ "configuration.Issuer: '{ configuration . Issuer } ' != expected '{ OpenIdConfigData . AADCommonV1Config . Issuer } '.") ;
252+ }
253+
254+ [ Fact ]
255+ public async Task VerifyInterlockGuardForGetConfigurationAsync ( )
256+ {
257+ ManualResetEvent waitEvent = new ManualResetEvent ( false ) ;
258+ ManualResetEvent signalEvent = new ManualResetEvent ( false ) ;
259+
260+ InMemoryDocumentRetriever inMemoryDocumentRetriever = InMemoryDocumentRetrieverWithEvents ( waitEvent , signalEvent ) ;
261+ waitEvent . Set ( ) ;
262+
263+ var configurationManager = new ConfigurationManager < OpenIdConnectConfiguration > (
264+ "AADCommonV1Json" ,
265+ new OpenIdConnectConfigurationRetriever ( ) ,
266+ inMemoryDocumentRetriever ) ;
267+
268+ OpenIdConnectConfiguration configuration = await configurationManager . GetConfigurationAsync ( ) ;
269+
270+ // InMemoryDocumentRetrieverWithEvents will block until waitEvent.Set() is called.
271+ // The GetConfigurationAsync to update config will not have finished before the next GetConfigurationAsync() is called.
272+ // The guard '_syncAfter' will not block as we set it to DateTimeOffset.MinValue.
273+ // Interlocked guard should block.
274+ // Configuration should be AADCommonV1Config
275+
276+ waitEvent . Reset ( ) ;
277+ signalEvent . Reset ( ) ;
278+
279+ TestUtilities . SetField ( configurationManager , "_syncAfter" , DateTimeOffset . MinValue ) ;
280+ await configurationManager . GetConfigurationAsync ( CancellationToken . None ) ;
281+
282+ // InMemoryDocumentRetrieverWithEvents will signal when it is OK to change the MetadataAddress
283+ // otherwise, it may be the case that the MetadataAddress is changed before the previous Task has finished.
284+ signalEvent . WaitOne ( ) ;
285+
286+ // AADCommonV1Json would have been passed to the the previous retriever, which is blocked on an event.
287+ configurationManager . MetadataAddress = "AADCommonV2Json" ;
288+ await configurationManager . GetConfigurationAsync ( CancellationToken . None ) ;
289+
290+ // Set the event to release the lock and let the previous retriever finish.
291+ waitEvent . Set ( ) ;
292+
293+ // Configuration should be AADCommonV1Config
294+ configuration = await configurationManager . GetConfigurationAsync ( ) ;
295+ Assert . True ( configuration . Issuer . Equals ( OpenIdConfigData . AADCommonV1Config . Issuer ) ,
296+ $ "configuration.Issuer from configurationManager was not as expected," +
297+ $ " configuration.Issuer: '{ configuration . Issuer } ' != expected: '{ OpenIdConfigData . AADCommonV1Config . Issuer } '.") ;
298+ }
299+
212300 [ Fact ]
213301 public async Task BootstrapRefreshIntervalTest ( )
214302 {
215303 var context = new CompareContext ( $ "{ this } .BootstrapRefreshIntervalTest") ;
216304
217- var documentRetriever = new HttpDocumentRetriever ( HttpResponseMessageUtils . SetupHttpClientThatReturns ( "OpenIdConnectMetadata.json" , HttpStatusCode . NotFound ) ) ;
218- var configManager = new ConfigurationManager < OpenIdConnectConfiguration > ( "OpenIdConnectMetadata.json" , new OpenIdConnectConfigurationRetriever ( ) , documentRetriever ) { RefreshInterval = TimeSpan . FromSeconds ( 2 ) } ;
305+ var documentRetriever = new HttpDocumentRetriever (
306+ HttpResponseMessageUtils . SetupHttpClientThatReturns ( "OpenIdConnectMetadata.json" , HttpStatusCode . NotFound ) ) ;
307+
308+ var configManager = new ConfigurationManager < OpenIdConnectConfiguration > (
309+ "OpenIdConnectMetadata.json" ,
310+ new OpenIdConnectConfigurationRetriever ( ) ,
311+ documentRetriever )
312+ { RefreshInterval = TimeSpan . FromSeconds ( 2 ) } ;
219313
220- // First time to fetch metadata.
314+ // ConfigurationManager._syncAfter is set to DateTimeOffset.MinValue on startup
315+ // If obtaining the metadata fails due to error, the value should not change
221316 try
222317 {
223318 var configuration = await configManager . GetConfigurationAsync ( ) ;
224319 }
225320 catch ( Exception firstFetchMetadataFailure )
226321 {
227- // Refresh interval is BootstrapRefreshInterval
228- var syncAfter = configManager . GetType ( ) . GetField ( "_syncAfter" , BindingFlags . NonPublic | BindingFlags . Instance ) . GetValue ( configManager ) ;
229- if ( ( DateTimeOffset ) syncAfter > DateTime . UtcNow + TimeSpan . FromSeconds ( 2 ) )
230- context . AddDiff ( $ "Expected the refresh interval is longer than 2 seconds .") ;
322+ // _syncAfter should not have been changed, because the fetch failed.
323+ var syncAfter = TestUtilities . GetField ( configManager , "_syncAfter" ) ;
324+ if ( ( DateTimeOffset ) syncAfter != DateTimeOffset . MinValue )
325+ context . AddDiff ( $ "ConfigurationManager._syncAfter: ' { syncAfter } ' should equal ' { DateTimeOffset . MinValue } ' .") ;
231326
232327 if ( firstFetchMetadataFailure . InnerException == null )
233328 context . AddDiff ( $ "Expected exception to contain inner exception for fetch metadata failure.") ;
@@ -243,11 +338,10 @@ public async Task BootstrapRefreshIntervalTest()
243338 if ( secondFetchMetadataFailure . InnerException == null )
244339 context . AddDiff ( $ "Expected exception to contain inner exception for fetch metadata failure.") ;
245340
246- syncAfter = configManager . GetType ( ) . GetField ( "_syncAfter" , BindingFlags . NonPublic | BindingFlags . Instance ) . GetValue ( configManager ) ;
247-
248- // Refresh interval is RefreshInterval
249- if ( ( DateTimeOffset ) syncAfter > DateTime . UtcNow + configManager . RefreshInterval )
250- context . AddDiff ( $ "Expected the refresh interval is longer than 2 seconds.") ;
341+ // _syncAfter should not have been changed, because the fetch failed.
342+ syncAfter = TestUtilities . GetField ( configManager , "_syncAfter" ) ;
343+ if ( ( DateTimeOffset ) syncAfter != DateTimeOffset . MinValue )
344+ context . AddDiff ( $ "ConfigurationManager._syncAfter: '{ syncAfter } ' should equal '{ DateTimeOffset . MinValue } '.") ;
251345
252346 IdentityComparer . AreEqual ( firstFetchMetadataFailure , secondFetchMetadataFailure , context ) ;
253347 }
@@ -808,6 +902,20 @@ public static TheoryData<ConfigurationManagerTheoryData<OpenIdConnectConfigurati
808902 { "https://login.microsoftonline.com/common/discovery/v2.0/keys" , OpenIdConfigData . AADCommonV2JwksString }
809903 } ) ;
810904
905+ private static InMemoryDocumentRetriever InMemoryDocumentRetrieverWithEvents ( ManualResetEvent waitEvent , ManualResetEvent signalEvent )
906+ {
907+ return new InMemoryDocumentRetriever (
908+ new Dictionary < string , string >
909+ {
910+ { "AADCommonV1Json" , OpenIdConfigData . AADCommonV1Json } ,
911+ { "https://login.microsoftonline.com/common/discovery/keys" , OpenIdConfigData . AADCommonV1JwksString } ,
912+ { "AADCommonV2Json" , OpenIdConfigData . AADCommonV2Json } ,
913+ { "https://login.microsoftonline.com/common/discovery/v2.0/keys" , OpenIdConfigData . AADCommonV2JwksString }
914+ } ,
915+ waitEvent ,
916+ signalEvent ) ;
917+ }
918+
811919 public class ConfigurationManagerTheoryData < T > : TheoryDataBase where T : class
812920 {
813921 public ConfigurationManager < T > ConfigurationManager { get ; set ; }
0 commit comments