@@ -461,7 +461,13 @@ async fn test_join_by_ip_ssm_with_sources(cptestctx: &ControlPlaneTestContext) {
461461}
462462
463463/// Test SSM join-by-IP without sources should fail.
464+ ///
464465/// SSM addresses (232.0.0.0/8) require source IPs for implicit creation.
466+ ///
467+ /// This is the canonical test for SSM source validation. The validation
468+ /// code path is shared regardless of how you join (by IP, name, or ID) -
469+ /// all routes converge on the same `instance_multicast_group_join` logic
470+ /// that checks `is_ssm_address()` and rejects joins without sources.
465471#[ nexus_test]
466472async fn test_join_by_ip_ssm_without_sources_fails (
467473 cptestctx : & ControlPlaneTestContext ,
@@ -516,158 +522,6 @@ async fn test_join_by_ip_ssm_without_sources_fails(
516522 cleanup_instances ( cptestctx, client, project_name, & [ instance_name] ) . await ;
517523}
518524
519- /// Test joining an existing SSM group by ID without sources should fail.
520- ///
521- /// This tests the SSM validation for join-by-ID path: if an SSM group exists
522- /// (created by first instance with sources), a second instance cannot join
523- /// by group ID without providing sources.
524- #[ nexus_test]
525- async fn test_join_existing_ssm_group_by_id_without_sources_fails (
526- cptestctx : & ControlPlaneTestContext ,
527- ) {
528- let client = & cptestctx. external_client ;
529- let project_name = "ssm-id-fail-project" ;
530-
531- // Setup: SSM pool
532- let ( _, _, _ssm_pool) = ops:: join3 (
533- create_project ( client, project_name) ,
534- create_default_ip_pool ( client) ,
535- create_multicast_ip_pool_with_range (
536- client,
537- "ssm-id-fail-pool" ,
538- ( 232 , 40 , 0 , 1 ) ,
539- ( 232 , 40 , 0 , 255 ) ,
540- ) ,
541- )
542- . await ;
543-
544- create_instance ( client, project_name, "ssm-id-inst-1" ) . await ;
545- create_instance ( client, project_name, "ssm-id-inst-2" ) . await ;
546-
547- // First instance creates SSM group with sources
548- let ssm_ip = "232.40.0.100" ;
549- let source_ip: IpAddr = "10.40.0.1" . parse ( ) . unwrap ( ) ;
550- let join_url_1 = format ! (
551- "/v1/instances/ssm-id-inst-1/multicast-groups/{ssm_ip}?project={project_name}"
552- ) ;
553-
554- let join_body_1 =
555- InstanceMulticastGroupJoin { source_ips : Some ( vec ! [ source_ip] ) } ;
556- let member_1: MulticastGroupMember =
557- put_upsert ( client, & join_url_1, & join_body_1) . await ;
558-
559- let group_id = member_1. multicast_group_id ;
560-
561- // Second instance tries to join by group ID WITHOUT sources - should fail
562- let join_url_by_id = format ! (
563- "/v1/instances/ssm-id-inst-2/multicast-groups/{group_id}?project={project_name}"
564- ) ;
565-
566- let error = NexusRequest :: new (
567- RequestBuilder :: new ( client, Method :: PUT , & join_url_by_id)
568- . body ( Some ( & InstanceMulticastGroupJoin {
569- source_ips : None , // No sources!
570- } ) )
571- . expect_status ( Some ( StatusCode :: BAD_REQUEST ) ) ,
572- )
573- . authn_as ( AuthnMode :: PrivilegedUser )
574- . execute ( )
575- . await
576- . expect ( "Join by ID without sources should fail for SSM group" ) ;
577-
578- let error_body: dropshot:: HttpErrorResponseBody =
579- error. parsed_body ( ) . unwrap ( ) ;
580- assert ! (
581- error_body. message. contains( "SSM" )
582- || error_body. message. contains( "source" ) ,
583- "Error should mention SSM or source IPs: {}" ,
584- error_body. message
585- ) ;
586-
587- let expected_group_name = format ! ( "mcast-{}" , ssm_ip. replace( '.' , "-" ) ) ;
588- cleanup_instances (
589- cptestctx,
590- client,
591- project_name,
592- & [ "ssm-id-inst-1" , "ssm-id-inst-2" ] ,
593- )
594- . await ;
595- wait_for_group_deleted ( client, & expected_group_name) . await ;
596- }
597-
598- /// Test joining an existing SSM group by NAME without sources should fail.
599- #[ nexus_test]
600- async fn test_join_existing_ssm_group_by_name_without_sources_fails (
601- cptestctx : & ControlPlaneTestContext ,
602- ) {
603- let client = & cptestctx. external_client ;
604- let project_name = "ssm-name-fail-project" ;
605-
606- // Setup: SSM pool
607- let ( _, _, _ssm_pool) = ops:: join3 (
608- create_project ( client, project_name) ,
609- create_default_ip_pool ( client) ,
610- create_multicast_ip_pool_with_range (
611- client,
612- "ssm-name-fail-pool" ,
613- ( 232 , 45 , 0 , 1 ) ,
614- ( 232 , 45 , 0 , 100 ) ,
615- ) ,
616- )
617- . await ;
618-
619- create_instance ( client, project_name, "ssm-name-inst-1" ) . await ;
620- create_instance ( client, project_name, "ssm-name-inst-2" ) . await ;
621-
622- // First instance creates SSM group with sources
623- let ssm_ip = "232.45.0.50" ;
624- let join_url = format ! (
625- "/v1/instances/ssm-name-inst-1/multicast-groups/{ssm_ip}?project={project_name}"
626- ) ;
627- let join_body = InstanceMulticastGroupJoin {
628- source_ips : Some ( vec ! [ "10.0.0.1" . parse( ) . unwrap( ) ] ) ,
629- } ;
630-
631- put_upsert :: < _ , MulticastGroupMember > ( client, & join_url, & join_body) . await ;
632-
633- // Get the group's auto-generated name
634- let expected_group_name = format ! ( "mcast-{}" , ssm_ip. replace( '.' , "-" ) ) ;
635-
636- // Second instance tries to join by NAME without sources - should fail
637- let join_by_name_url = format ! (
638- "/v1/instances/ssm-name-inst-2/multicast-groups/{expected_group_name}?project={project_name}"
639- ) ;
640- let join_body_no_sources = InstanceMulticastGroupJoin { source_ips : None } ;
641-
642- let error = NexusRequest :: new (
643- RequestBuilder :: new ( client, Method :: PUT , & join_by_name_url)
644- . body ( Some ( & join_body_no_sources) )
645- . expect_status ( Some ( StatusCode :: BAD_REQUEST ) ) ,
646- )
647- . authn_as ( AuthnMode :: PrivilegedUser )
648- . execute ( )
649- . await
650- . expect ( "Join by name without sources should fail for SSM group" ) ;
651-
652- let error_body: dropshot:: HttpErrorResponseBody =
653- error. parsed_body ( ) . unwrap ( ) ;
654- assert ! (
655- error_body. message. contains( "SSM" )
656- || error_body. message. contains( "source" ) ,
657- "Error should mention SSM or source IPs: {}" ,
658- error_body. message
659- ) ;
660-
661- cleanup_instances (
662- cptestctx,
663- client,
664- project_name,
665- & [ "ssm-name-inst-1" , "ssm-name-inst-2" ] ,
666- )
667- . await ;
668- wait_for_group_deleted ( client, & expected_group_name) . await ;
669- }
670-
671525/// Test that SSM join-by-IP with empty sources array fails.
672526///
673527/// `source_ips: Some(vec![])` (empty array) is treated the same as
@@ -725,83 +579,6 @@ async fn test_ssm_with_empty_sources_array_fails(
725579 cleanup_instances ( cptestctx, client, project_name, & [ instance_name] ) . await ;
726580}
727581
728- /// Test joining an existing SSM group by IP without sources fails.
729- ///
730- /// When an SSM group already exists (created by first instance with sources),
731- /// a second instance joining by IP should still fail without sources since
732- /// the group is SSM.
733- #[ nexus_test]
734- async fn test_join_existing_ssm_group_by_ip_without_sources_fails (
735- cptestctx : & ControlPlaneTestContext ,
736- ) {
737- let client = & cptestctx. external_client ;
738- let project_name = "ssm-ip-existing-fail-project" ;
739-
740- // Setup: SSM pool
741- let ( _, _, _ssm_pool) = ops:: join3 (
742- create_project ( client, project_name) ,
743- create_default_ip_pool ( client) ,
744- create_multicast_ip_pool_with_range (
745- client,
746- "ssm-ip-existing-fail-pool" ,
747- ( 232 , 47 , 0 , 1 ) ,
748- ( 232 , 47 , 0 , 100 ) ,
749- ) ,
750- )
751- . await ;
752-
753- create_instance ( client, project_name, "ssm-ip-inst-1" ) . await ;
754- create_instance ( client, project_name, "ssm-ip-inst-2" ) . await ;
755-
756- // First instance creates SSM group with sources
757- let ssm_ip = "232.47.0.50" ;
758- let join_url = format ! (
759- "/v1/instances/ssm-ip-inst-1/multicast-groups/{ssm_ip}?project={project_name}"
760- ) ;
761- let join_body = InstanceMulticastGroupJoin {
762- source_ips : Some ( vec ! [ "10.0.0.1" . parse( ) . unwrap( ) ] ) ,
763- } ;
764-
765- put_upsert :: < _ , MulticastGroupMember > ( client, & join_url, & join_body) . await ;
766-
767- let expected_group_name = format ! ( "mcast-{}" , ssm_ip. replace( '.' , "-" ) ) ;
768-
769- // Second instance tries to join by IP without sources - should fail
770- // Even though the group exists, SSM still requires sources
771- let join_url_2 = format ! (
772- "/v1/instances/ssm-ip-inst-2/multicast-groups/{ssm_ip}?project={project_name}"
773- ) ;
774- let join_body_no_sources = InstanceMulticastGroupJoin { source_ips : None } ;
775-
776- let error = NexusRequest :: new (
777- RequestBuilder :: new ( client, Method :: PUT , & join_url_2)
778- . body ( Some ( & join_body_no_sources) )
779- . expect_status ( Some ( StatusCode :: BAD_REQUEST ) ) ,
780- )
781- . authn_as ( AuthnMode :: PrivilegedUser )
782- . execute ( )
783- . await
784- . expect ( "Join existing SSM group by IP without sources should fail" ) ;
785-
786- let error_body: dropshot:: HttpErrorResponseBody =
787- error. parsed_body ( ) . unwrap ( ) ;
788- assert ! (
789- error_body. message. contains( "SSM" )
790- || error_body. message. contains( "source" ) ,
791- "Error should mention SSM or source IPs: {}" ,
792- error_body. message
793- ) ;
794-
795- cleanup_instances (
796- cptestctx,
797- client,
798- project_name,
799- & [ "ssm-ip-inst-1" , "ssm-ip-inst-2" ] ,
800- )
801- . await ;
802- wait_for_group_deleted ( client, & expected_group_name) . await ;
803- }
804-
805582/// Test join-by-IP with IP not in any pool should fail.
806583#[ nexus_test]
807584async fn test_join_by_ip_not_in_pool_fails (
0 commit comments