|
16 | 16 | //! - SSM (Source-Specific): 232.0.0.0/8, sources required per-member |
17 | 17 | //! - SSM validation: Every SSM member must specify sources (S,G subscription) |
18 | 18 | //! - New groups: Validated before creation |
19 | | -//! - Existing groups: Validated on join (by IP, name, or ID) |
| 19 | +//! - Existing groups: Validated on join (all paths share `instance_multicast_group_join`) |
20 | 20 | //! - Empty sources array: Treated same as None (invalid for SSM) |
21 | 21 | //! - Source IP validation: ASM can have sources; SSM requires them |
22 | 22 | //! - Pool validation: IP must be in a linked multicast pool |
@@ -461,7 +461,13 @@ async fn test_join_by_ip_ssm_with_sources(cptestctx: &ControlPlaneTestContext) { |
461 | 461 | } |
462 | 462 |
|
463 | 463 | /// Test SSM join-by-IP without sources should fail. |
| 464 | +/// |
464 | 465 | /// 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. |
465 | 471 | #[nexus_test] |
466 | 472 | async fn test_join_by_ip_ssm_without_sources_fails( |
467 | 473 | cptestctx: &ControlPlaneTestContext, |
@@ -516,158 +522,6 @@ async fn test_join_by_ip_ssm_without_sources_fails( |
516 | 522 | cleanup_instances(cptestctx, client, project_name, &[instance_name]).await; |
517 | 523 | } |
518 | 524 |
|
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 | | - |
671 | 525 | /// Test that SSM join-by-IP with empty sources array fails. |
672 | 526 | /// |
673 | 527 | /// `source_ips: Some(vec![])` (empty array) is treated the same as |
@@ -725,83 +579,6 @@ async fn test_ssm_with_empty_sources_array_fails( |
725 | 579 | cleanup_instances(cptestctx, client, project_name, &[instance_name]).await; |
726 | 580 | } |
727 | 581 |
|
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 | | - |
805 | 582 | /// Test join-by-IP with IP not in any pool should fail. |
806 | 583 | #[nexus_test] |
807 | 584 | async fn test_join_by_ip_not_in_pool_fails( |
|
0 commit comments