@@ -3,24 +3,44 @@ use std::{collections::BTreeMap, convert::TryFrom};
33
44use assert_matches:: assert_matches;
55
6- use candid:: Encode ;
6+ use candid:: { Decode , Encode , Principal } ;
77use canister_test:: PrincipalId ;
88use dfn_candid:: candid;
99use ic_base_types:: NodeId ;
10+ use ic_nervous_system_integration_tests:: pocket_ic_helpers:: nns:: registry:: decode_registry_value;
11+ use ic_nns_constants:: {
12+ ENGINE_CONTROLLER_CANISTER_ID , GOVERNANCE_CANISTER_ID , REGISTRY_CANISTER_ID ,
13+ } ;
1014use ic_nns_test_utils:: {
1115 itest_helpers:: {
1216 forward_call_via_universal_canister, set_up_registry_canister, set_up_universal_canister,
1317 state_machine_test_on_nns_subnet,
1418 } ,
15- registry:: { get_value_or_panic, prepare_registry, prepare_registry_with_two_node_sets} ,
19+ registry:: {
20+ INITIAL_MUTATION_ID , get_value_or_panic, invariant_compliant_mutation_as_atomic_req,
21+ prepare_registry, prepare_registry_with_two_node_sets,
22+ } ,
23+ } ;
24+ use ic_protobuf:: registry:: {
25+ node:: v1:: { NodeRecord , NodeRewardType } ,
26+ subnet:: v1:: { SubnetListRecord , SubnetRecord } ,
1627} ;
17- use ic_protobuf:: registry:: subnet:: v1:: { SubnetListRecord , SubnetRecord } ;
1828use ic_registry_keys:: { make_subnet_list_record_key, make_subnet_record_key} ;
29+ use pocket_ic:: PocketIcBuilder ;
30+ use pocket_ic:: RejectResponse ;
31+ use pocket_ic:: nonblocking:: PocketIc ;
1932use registry_canister:: {
2033 init:: RegistryCanisterInitPayloadBuilder ,
2134 mutations:: do_change_subnet_membership:: ChangeSubnetMembershipPayload ,
2235} ;
2336
37+ mod common;
38+
39+ use common:: test_helpers:: {
40+ install_registry_canister_with_payload_builder, prepare_registry_with_cloud_engine_subnet,
41+ prepare_registry_with_nodes_from_template,
42+ } ;
43+
2444#[ test]
2545fn test_the_anonymous_user_cannot_change_subnet_membership ( ) {
2646 state_machine_test_on_nns_subnet ( |runtime| {
@@ -410,3 +430,160 @@ fn test_change_subnet_membership_duplicate_nodes() {
410430 }
411431 } ) ;
412432}
433+
434+ /// Sets up a `PocketIc` with the registry canister installed, containing a
435+ /// CloudEngine subnet plus one extra unassigned type-4 node that can be
436+ /// swapped into the subnet.
437+ async fn setup_cloud_engine_registry ( ) -> ( PocketIc , Principal , NodeId , NodeId ) {
438+ let pocket_ic = PocketIcBuilder :: new ( ) . with_nns_subnet ( ) . build_async ( ) . await ;
439+
440+ let ( cloud_engine_mutate, cloud_engine_subnet_id) =
441+ prepare_registry_with_cloud_engine_subnet ( 4 , INITIAL_MUTATION_ID ) ;
442+
443+ // Prepare an extra unassigned type-4 node to swap into the subnet.
444+ // Type-4 is required because the CloudEngine subnet invariant enforces
445+ // that all members are type-4 nodes.
446+ let extra_node_template = NodeRecord {
447+ node_operator_id : PrincipalId :: new_user_test_id ( 999 ) . into_vec ( ) ,
448+ node_reward_type : Some ( NodeRewardType :: Type4 as i32 ) ,
449+ ..Default :: default ( )
450+ } ;
451+ // Use a starting mutation id well outside the range consumed by the
452+ // CloudEngine setup so IPs / test ids do not collide.
453+ let ( extra_node_mutate, extra_node_ids_and_pks) = prepare_registry_with_nodes_from_template (
454+ 1 ,
455+ INITIAL_MUTATION_ID + 100 ,
456+ extra_node_template,
457+ ) ;
458+ let extra_node_id = * extra_node_ids_and_pks
459+ . keys ( )
460+ . next ( )
461+ . expect ( "expected one extra unassigned node" ) ;
462+
463+ // Any current member of the CloudEngine subnet can be picked as the one
464+ // to remove; take the first one from the subnet record.
465+ let mut builder = RegistryCanisterInitPayloadBuilder :: new ( ) ;
466+ builder. push_init_mutate_request ( invariant_compliant_mutation_as_atomic_req ( 0 ) ) ;
467+ builder. push_init_mutate_request ( cloud_engine_mutate) ;
468+ builder. push_init_mutate_request ( extra_node_mutate) ;
469+ install_registry_canister_with_payload_builder ( & pocket_ic, builder. build ( ) , true ) . await ;
470+
471+ // Read a current member of the CloudEngine subnet to use as the removal
472+ // target.
473+ let subnet_record = decode_registry_value :: < SubnetRecord > (
474+ & pocket_ic,
475+ make_subnet_record_key ( cloud_engine_subnet_id) ,
476+ )
477+ . await ;
478+ let member_to_remove = NodeId :: from (
479+ PrincipalId :: try_from ( subnet_record. membership . first ( ) . unwrap ( ) . as_slice ( ) ) . unwrap ( ) ,
480+ ) ;
481+
482+ (
483+ pocket_ic,
484+ cloud_engine_subnet_id. get ( ) . 0 ,
485+ extra_node_id,
486+ member_to_remove,
487+ )
488+ }
489+
490+ #[ tokio:: test]
491+ async fn test_engine_controller_can_change_membership_of_cloud_engine_subnet ( ) {
492+ let ( pocket_ic, cloud_engine_subnet_id, node_to_add, node_to_remove) =
493+ setup_cloud_engine_registry ( ) . await ;
494+
495+ let payload = ChangeSubnetMembershipPayload {
496+ subnet_id : PrincipalId :: from ( cloud_engine_subnet_id) ,
497+ node_ids_add : vec ! [ node_to_add] ,
498+ node_ids_remove : vec ! [ node_to_remove] ,
499+ } ;
500+
501+ // The engine controller performs a 1-for-1 swap on the CloudEngine subnet.
502+ // This is expected to succeed.
503+ let response: Vec < u8 > = pocket_ic
504+ . update_call (
505+ REGISTRY_CANISTER_ID . get ( ) . 0 ,
506+ ENGINE_CONTROLLER_CANISTER_ID . get ( ) . 0 ,
507+ "change_subnet_membership" ,
508+ Encode ! ( & payload) . unwrap ( ) ,
509+ )
510+ . await
511+ . unwrap ( ) ;
512+ Decode ! ( & response, ( ) ) . unwrap ( ) ;
513+
514+ // Verify the membership actually changed.
515+ let subnet_record = decode_registry_value :: < SubnetRecord > (
516+ & pocket_ic,
517+ make_subnet_record_key ( ic_base_types:: SubnetId :: from ( PrincipalId :: from (
518+ cloud_engine_subnet_id,
519+ ) ) ) ,
520+ )
521+ . await ;
522+ let members = subnet_record
523+ . membership
524+ . iter ( )
525+ . map ( |bytes| NodeId :: from ( PrincipalId :: try_from ( bytes. as_slice ( ) ) . unwrap ( ) ) )
526+ . collect :: < Vec < NodeId > > ( ) ;
527+ assert ! (
528+ members. contains( & node_to_add) ,
529+ "swapped-in node {node_to_add} should now be a subnet member, got {members:?}"
530+ ) ;
531+ assert ! (
532+ !members. contains( & node_to_remove) ,
533+ "swapped-out node {node_to_remove} should no longer be a subnet member, got {members:?}"
534+ ) ;
535+ }
536+
537+ #[ tokio:: test]
538+ async fn test_engine_controller_cannot_change_membership_of_non_cloud_engine_subnet ( ) {
539+ // Set up a registry that contains only the invariant-compliant system
540+ // subnet (which is not a CloudEngine).
541+ let pocket_ic = PocketIcBuilder :: new ( ) . with_nns_subnet ( ) . build_async ( ) . await ;
542+ let mut builder = RegistryCanisterInitPayloadBuilder :: new ( ) ;
543+ builder. push_init_mutate_request ( invariant_compliant_mutation_as_atomic_req ( 0 ) ) ;
544+ install_registry_canister_with_payload_builder ( & pocket_ic, builder. build ( ) , true ) . await ;
545+
546+ // Identify the (only) non-CloudEngine subnet.
547+ let subnet_list =
548+ decode_registry_value :: < SubnetListRecord > ( & pocket_ic, make_subnet_list_record_key ( ) ) . await ;
549+ let non_cloud_engine_subnet =
550+ Principal :: try_from ( subnet_list. subnets . first ( ) . unwrap ( ) . as_slice ( ) ) . unwrap ( ) ;
551+
552+ let payload = ChangeSubnetMembershipPayload {
553+ subnet_id : PrincipalId :: from ( non_cloud_engine_subnet) ,
554+ node_ids_add : vec ! [ ] ,
555+ node_ids_remove : vec ! [ ] ,
556+ } ;
557+
558+ // First, establish that this payload is itself valid by having governance
559+ // (which is exempt from the CloudEngine restriction) successfully apply
560+ // it as a no-op change against the non-CloudEngine subnet.
561+ let response: Vec < u8 > = pocket_ic
562+ . update_call (
563+ REGISTRY_CANISTER_ID . get ( ) . 0 ,
564+ GOVERNANCE_CANISTER_ID . get ( ) . 0 ,
565+ "change_subnet_membership" ,
566+ Encode ! ( & payload) . unwrap ( ) ,
567+ )
568+ . await
569+ . unwrap ( ) ;
570+ Decode ! ( & response, ( ) ) . unwrap ( ) ;
571+
572+ // Now the engine controller submits the exact same payload; since the
573+ // target subnet is not a CloudEngine, the call must be rejected.
574+ let response: Result < Vec < u8 > , RejectResponse > = pocket_ic
575+ . update_call (
576+ REGISTRY_CANISTER_ID . get ( ) . 0 ,
577+ ENGINE_CONTROLLER_CANISTER_ID . get ( ) . 0 ,
578+ "change_subnet_membership" ,
579+ Encode ! ( & payload) . unwrap ( ) ,
580+ )
581+ . await ;
582+
583+ let err = response. unwrap_err ( ) ;
584+ assert ! (
585+ err. reject_message
586+ . contains( "may only change membership of CloudEngine subnets" ) ,
587+ "expected a CloudEngine-specific rejection message, got {err:?}"
588+ ) ;
589+ }
0 commit comments