@@ -594,9 +594,12 @@ fn test_service_definition_serialization() {
594594}
595595#[ tokio:: test]
596596async fn test_service_definition_logo_urls_resolve ( ) {
597+ use backon:: { ExponentialBuilder , Retryable } ;
598+ use futures:: stream:: { self , StreamExt } ;
599+
597600 let registry = ServiceDefinitionRegistry :: all_service_definitions ( ) ;
598601 let client = reqwest:: Client :: builder ( )
599- . timeout ( std:: time:: Duration :: from_secs ( 5 ) )
602+ . timeout ( std:: time:: Duration :: from_secs ( 10 ) )
600603 . build ( )
601604 . expect ( "Failed to create HTTP client" ) ;
602605
@@ -607,89 +610,126 @@ async fn test_service_definition_logo_urls_resolve() {
607610 "cdn.prod.website-files.com" ,
608611 ] ;
609612
610- for service in registry {
611- let logo_url = service. logo_url ( ) ;
612-
613- // Skip services without logo URLs
614- if logo_url. is_empty ( ) {
615- continue ;
616- }
617-
618- // Check if it's a local file path or external URL
619- if logo_url. starts_with ( '/' ) {
620- // Local file path like /logos/scanopy-logo.png
621- assert ! (
622- logo_url. starts_with( "/logos/" ) ,
623- "Service '{}' has local logo URL '{}' that doesn't start with /logos/" ,
624- ServiceDefinition :: name( & service) ,
625- logo_url
626- ) ;
627- // We can't verify local files exist in tests, so just validate the path format
628- continue ;
629- }
613+ // Collect services with external logo URLs to validate
614+ let services_to_check: Vec < _ > = registry
615+ . into_iter ( )
616+ . filter_map ( |service| {
617+ let logo_url = service. logo_url ( ) ;
630618
631- // Must be a URL - parse it
632- let url = match reqwest:: Url :: parse ( logo_url) {
633- Ok ( url) => url,
634- Err ( e) => {
635- panic ! (
636- "Service '{}' has invalid logo URL '{}': {}" ,
637- ServiceDefinition :: name( & service) ,
638- logo_url,
639- e
640- ) ;
619+ // Skip services without logo URLs
620+ if logo_url. is_empty ( ) {
621+ return None ;
641622 }
642- } ;
643-
644- // Check domain is in allowed list
645- let domain = url. domain ( ) . unwrap_or ( "" ) ;
646- let is_allowed = ALLOWED_DOMAINS
647- . iter ( )
648- . any ( |allowed| domain. ends_with ( allowed) ) ;
649-
650- assert ! (
651- is_allowed,
652- "Service '{}' has logo URL '{}' from unauthorized domain '{}'. \
653- Allowed domains: {}",
654- ServiceDefinition :: name( & service) ,
655- logo_url,
656- domain,
657- ALLOWED_DOMAINS . join( ", " )
658- ) ;
659623
660- // Attempt to fetch the logo URL
661- match client . head ( logo_url) . send ( ) . await {
662- Ok ( response ) => {
624+ // Check if it's a local file path
625+ if logo_url. starts_with ( '/' ) {
626+ // Local file path like /logos/scanopy-logo.png
663627 assert ! (
664- response . status ( ) . is_success ( ) ,
665- "Service '{}' has logo URL '{}' that returned status {} " ,
628+ logo_url . starts_with ( "/logos/" ) ,
629+ "Service '{}' has local logo URL '{}' that doesn't start with /logos/ " ,
666630 ServiceDefinition :: name( & service) ,
667- logo_url,
668- response. status( )
631+ logo_url
669632 ) ;
633+ return None ;
634+ }
670635
671- // Verify Content-Type is an image
672- if let Some ( content_type) = response. headers ( ) . get ( "content-type" ) {
673- let content_type_str = content_type. to_str ( ) . unwrap_or ( "" ) ;
674- assert ! (
675- content_type_str. starts_with( "image/" )
676- || content_type_str. starts_with( "text/plain" ) ,
677- "Service '{}' has logo URL '{}' with non-image Content-Type: {}" ,
636+ // Must be a URL - parse it
637+ let url = match reqwest:: Url :: parse ( logo_url) {
638+ Ok ( url) => url,
639+ Err ( e) => {
640+ panic ! (
641+ "Service '{}' has invalid logo URL '{}': {}" ,
678642 ServiceDefinition :: name( & service) ,
679643 logo_url,
680- content_type_str
644+ e
681645 ) ;
682646 }
647+ } ;
648+
649+ // Check domain is in allowed list
650+ let domain = url. domain ( ) . unwrap_or ( "" ) ;
651+ let is_allowed = ALLOWED_DOMAINS
652+ . iter ( )
653+ . any ( |allowed| domain. ends_with ( allowed) ) ;
654+
655+ assert ! (
656+ is_allowed,
657+ "Service '{}' has logo URL '{}' from unauthorized domain '{}'. \
658+ Allowed domains: {}",
659+ ServiceDefinition :: name( & service) ,
660+ logo_url,
661+ domain,
662+ ALLOWED_DOMAINS . join( ", " )
663+ ) ;
664+
665+ Some ( ( service. name ( ) . to_string ( ) , logo_url. to_string ( ) ) )
666+ } )
667+ . collect ( ) ;
668+
669+ // Fetch all logo URLs in parallel with retries
670+ let results: Vec < Result < ( ) , String > > = stream:: iter ( services_to_check)
671+ . map ( |( service_name, logo_url) | {
672+ let client = client. clone ( ) ;
673+ async move {
674+ let fetch_with_retry = || {
675+ let client = client. clone ( ) ;
676+ let url = logo_url. clone ( ) ;
677+ async move {
678+ let response = client
679+ . head ( & url)
680+ . send ( )
681+ . await
682+ . map_err ( |e| format ! ( "request failed: {}" , e) ) ?;
683+
684+ if response. status ( ) . is_success ( ) {
685+ // Verify Content-Type is an image
686+ if let Some ( content_type) = response. headers ( ) . get ( "content-type" ) {
687+ let content_type_str = content_type. to_str ( ) . unwrap_or ( "" ) ;
688+ if !content_type_str. starts_with ( "image/" )
689+ && !content_type_str. starts_with ( "text/plain" )
690+ {
691+ return Err ( format ! (
692+ "non-image Content-Type: {}" ,
693+ content_type_str
694+ ) ) ;
695+ }
696+ }
697+ Ok ( ( ) )
698+ } else {
699+ Err ( format ! ( "returned status {}" , response. status( ) ) )
700+ }
701+ }
702+ } ;
703+
704+ fetch_with_retry
705+ . retry (
706+ ExponentialBuilder :: default ( )
707+ . with_max_times ( 3 )
708+ . with_min_delay ( std:: time:: Duration :: from_millis ( 500 ) )
709+ . with_max_delay ( std:: time:: Duration :: from_secs ( 5 ) ) ,
710+ )
711+ . await
712+ . map_err ( |e| {
713+ format ! (
714+ "Service '{}' has logo URL '{}' that failed to resolve: {}" ,
715+ service_name, logo_url, e
716+ )
717+ } )
683718 }
684- Err ( e) => {
685- panic ! (
686- "Service '{}' has logo URL '{}' that failed to resolve: {}" ,
687- ServiceDefinition :: name( & service) ,
688- logo_url,
689- e
690- ) ;
691- }
692- }
719+ } )
720+ . buffer_unordered ( 10 ) // Run up to 10 requests in parallel
721+ . collect ( )
722+ . await ;
723+
724+ // Collect all failures and report them together
725+ let failures: Vec < _ > = results. into_iter ( ) . filter_map ( |r| r. err ( ) ) . collect ( ) ;
726+
727+ if !failures. is_empty ( ) {
728+ panic ! (
729+ "Logo URL validation failed for {} service(s):\n {}" ,
730+ failures. len( ) ,
731+ failures. join( "\n " )
732+ ) ;
693733 }
694734}
695735
0 commit comments