@@ -18,6 +18,7 @@ import (
1818 ctrl "sigs.k8s.io/controller-runtime"
1919 "sigs.k8s.io/controller-runtime/pkg/client"
2020 "sigs.k8s.io/controller-runtime/pkg/client/fake"
21+ ctrlfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
2122)
2223
2324// getTestScheme returns a runtime.Scheme with all Milo APIs registered.
@@ -29,6 +30,29 @@ func getTestScheme() *runtime.Scheme {
2930 return scheme
3031}
3132
33+ // initFinalizer wires up the userInvitationFinalizer on a UserInvitationController so
34+ // that unit tests exercise the full finalizer lifecycle without a live manager.
35+ func initFinalizer (t * testing.T , uic * UserInvitationController ) {
36+ t .Helper ()
37+ uic .finalizer = ctrlfinalizer .NewFinalizers ()
38+ if err := uic .finalizer .Register (userInvitationFinalizerKey , & userInvitationFinalizer {
39+ client : uic .Client ,
40+ uiRelatedRoles : uic .uiRelatedRoles ,
41+ }); err != nil {
42+ t .Fatalf ("failed to register userInvitation finalizer: %v" , err )
43+ }
44+ }
45+
46+ // containsFinalizer returns true when the UserInvitation carries the standard finalizer string.
47+ func containsFinalizer (ui * iamv1alpha1.UserInvitation ) bool {
48+ for _ , f := range ui .Finalizers {
49+ if f == userInvitationFinalizerKey {
50+ return true
51+ }
52+ }
53+ return false
54+ }
55+
3256// TestUserInvitationController_createPolicyBinding verifies that createPolicyBinding creates a PolicyBinding CR.
3357func TestUserInvitationController_createPolicyBinding (t * testing.T ) {
3458 ctx := context .TODO ()
@@ -659,10 +683,21 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
659683 SystemNamespace : "milo-system" ,
660684 uiRelatedRoles : []iamv1alpha1.RoleReference {invitationRoleRef },
661685 }
686+ initFinalizer (t , uic )
687+
688+ // First reconcile registers the finalizer and returns early.
689+ if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
690+ t .Fatalf ("first reconcile (finalizer registration) error: %v" , err )
691+ }
692+ registered := & iamv1alpha1.UserInvitation {}
693+ _ = c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, registered )
694+ if ! containsFinalizer (registered ) {
695+ t .Fatalf ("expected finalizer to be registered after first reconcile" )
696+ }
662697
663- // First reconcile (Pending)
698+ // Second reconcile (Pending) — business logic now runs.
664699 if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
665- t .Fatalf ("first reconcile error: %v" , err )
700+ t .Fatalf ("second reconcile error: %v" , err )
666701 }
667702
668703 // Verify invitation-related PolicyBinding exists
@@ -671,11 +706,11 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
671706 t .Fatalf ("expected invitation PolicyBinding created: %v" , err )
672707 }
673708
674- // Check Pending condition true, Ready false
709+ // Check Pending condition true, Ready false after the business-logic reconcile.
675710 afterFirst := & iamv1alpha1.UserInvitation {}
676711 _ = c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, afterFirst )
677712 if ! meta .IsStatusConditionTrue (afterFirst .Status .Conditions , string (iamv1alpha1 .UserInvitationPendingCondition )) {
678- t .Fatalf ("Pending condition should be true after first reconcile" )
713+ t .Fatalf ("Pending condition should be true after pending reconcile" )
679714 }
680715 if meta .IsStatusConditionTrue (afterFirst .Status .Conditions , string (iamv1alpha1 .UserInvitationReadyCondition )) {
681716 t .Fatalf ("Ready condition should not be true before acceptance" )
@@ -713,9 +748,9 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
713748 t .Fatalf ("failed to update UI state: %v" , err )
714749 }
715750
716- // Second reconcile after state change
751+ // Third reconcile after state change (first two were finalizer registration + pending business logic).
717752 if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
718- t .Fatalf ("second reconcile error: %v" , err )
753+ t .Fatalf ("third reconcile error: %v" , err )
719754 }
720755
721756 // Verify OrganizationMembership created with roles
@@ -732,7 +767,13 @@ func TestUserInvitationController_Reconcile_StateTransitionCreatesBindings(t *te
732767 t .Fatalf ("unexpected role on OrganizationMembership: %+v" , om .Spec .Roles [0 ])
733768 }
734769
735- // The UserInvitation should now be deleted
770+ // The acceptance path calls Delete which sets DeletionTimestamp (object has finalizer).
771+ // A fourth reconcile triggers the finalizer, which strips the finalizer and lets the object be removed.
772+ if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
773+ t .Fatalf ("fourth reconcile (finalizer cleanup) error: %v" , err )
774+ }
775+
776+ // The UserInvitation should now be fully removed.
736777 if err := c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, & iamv1alpha1.UserInvitation {}); err == nil {
737778 t .Fatalf ("UserInvitation should be deleted after acceptance" )
738779 } else if ! apierr .IsNotFound (err ) {
@@ -786,10 +827,16 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
786827 c := builder .Build ()
787828
788829 uic := & UserInvitationController {Client : c , SystemNamespace : "milo-system" , uiRelatedRoles : []iamv1alpha1.RoleReference {invitationRoleRef }}
830+ initFinalizer (t , uic )
789831
790- // First reconcile: no User yet
832+ // First reconcile registers the finalizer and returns early.
791833 if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
792- t .Fatalf ("first reconcile error: %v" , err )
834+ t .Fatalf ("first reconcile (finalizer registration) error: %v" , err )
835+ }
836+
837+ // Second reconcile: no User yet — business logic runs but no PolicyBinding created.
838+ if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
839+ t .Fatalf ("second reconcile error: %v" , err )
793840 }
794841
795842 // Expect no PolicyBindings created
@@ -804,9 +851,9 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
804851 t .Fatalf ("failed to create user: %v" , err )
805852 }
806853
807- // Second reconcile: should create invitation PB and Pending condition
854+ // Third reconcile: should create invitation PB and Pending condition
808855 if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
809- t .Fatalf ("second reconcile error: %v" , err )
856+ t .Fatalf ("third reconcile error: %v" , err )
810857 }
811858
812859 // Verify InviteeUser now populated after user appears (before acceptance)
@@ -830,9 +877,9 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
830877 t .Fatalf ("update UI state: %v" , err )
831878 }
832879
833- // Third reconcile
880+ // Fourth reconcile: accepted state — creates OrganizationMembership and deletes the UserInvitation.
834881 if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
835- t .Fatalf ("third reconcile error: %v" , err )
882+ t .Fatalf ("fourth reconcile error: %v" , err )
836883 }
837884
838885 // Invitation should be deleted after acceptance; verified later
@@ -852,7 +899,13 @@ func TestUserInvitationController_Reconcile_UserCreatedLater(t *testing.T) {
852899 }
853900 }
854901
855- // UserInvitation should be deleted
902+ // The acceptance path calls Delete which sets DeletionTimestamp (object has finalizer).
903+ // A fifth reconcile triggers the finalizer, which strips the finalizer and lets the object be removed.
904+ if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
905+ t .Fatalf ("fifth reconcile (finalizer cleanup) error: %v" , err )
906+ }
907+
908+ // UserInvitation should now be fully removed.
856909 if err := c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, & iamv1alpha1.UserInvitation {}); err == nil {
857910 t .Fatalf ("UserInvitation should be deleted after acceptance" )
858911 } else if ! apierr .IsNotFound (err ) {
@@ -1060,3 +1113,130 @@ func TestUserInvitationController_grantAccessApproval(t *testing.T) {
10601113 })
10611114 }
10621115}
1116+
1117+ // TestUserInvitationController_Reconcile_FinalizerRegisteredOnFirstReconcile verifies that the first
1118+ // reconcile adds the finalizer string to the UserInvitation and returns without running business logic.
1119+ func TestUserInvitationController_Reconcile_FinalizerRegisteredOnFirstReconcile (t * testing.T ) {
1120+ ctx := context .TODO ()
1121+ scheme := getTestScheme ()
1122+
1123+ ui := & iamv1alpha1.UserInvitation {
1124+ ObjectMeta : metav1.ObjectMeta {Name : "inv" , Namespace : "default" , UID : types .UID ("ui-uid" )},
1125+ Spec : iamv1alpha1.UserInvitationSpec {
1126+ Email : "test@example.com" ,
1127+ OrganizationRef : resourcemanagerv1alpha1.OrganizationReference {Name : "org" },
1128+ State : iamv1alpha1 .UserInvitationStatePending ,
1129+ },
1130+ }
1131+
1132+ c := fake .NewClientBuilder ().WithScheme (scheme ).
1133+ WithStatusSubresource (& iamv1alpha1.UserInvitation {}).
1134+ WithObjects (ui .DeepCopy ()).
1135+ Build ()
1136+
1137+ uic := & UserInvitationController {
1138+ Client : c ,
1139+ SystemNamespace : "milo-system" ,
1140+ }
1141+ initFinalizer (t , uic )
1142+
1143+ // Before first reconcile: no finalizer present.
1144+ before := & iamv1alpha1.UserInvitation {}
1145+ _ = c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, before )
1146+ if containsFinalizer (before ) {
1147+ t .Fatalf ("expected no finalizer before first reconcile" )
1148+ }
1149+
1150+ // First reconcile should register the finalizer and return early.
1151+ result , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }})
1152+ if err != nil {
1153+ t .Fatalf ("first reconcile returned error: %v" , err )
1154+ }
1155+ if result .Requeue || result .RequeueAfter != 0 {
1156+ t .Fatalf ("expected empty result after finalizer registration, got %+v" , result )
1157+ }
1158+
1159+ // Finalizer should now be present.
1160+ after := & iamv1alpha1.UserInvitation {}
1161+ if err := c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, after ); err != nil {
1162+ t .Fatalf ("failed to fetch UserInvitation: %v" , err )
1163+ }
1164+ if ! containsFinalizer (after ) {
1165+ t .Fatalf ("expected finalizer %q to be set after first reconcile, got finalizers: %v" , userInvitationFinalizerKey , after .Finalizers )
1166+ }
1167+
1168+ // No status conditions should have been set (business logic did not run).
1169+ if len (after .Status .Conditions ) != 0 {
1170+ t .Fatalf ("expected no status conditions after finalizer-only reconcile, got %+v" , after .Status .Conditions )
1171+ }
1172+ }
1173+
1174+ // TestUserInvitationController_Reconcile_PolicyBindingsGCOnDeletion verifies that when a UserInvitation is
1175+ // deleted the finalizer removes any invitation-related PolicyBindings before allowing the object to be removed.
1176+ func TestUserInvitationController_Reconcile_PolicyBindingsGCOnDeletion (t * testing.T ) {
1177+ ctx := context .TODO ()
1178+ scheme := getTestScheme ()
1179+
1180+ invitationRoleRef := iamv1alpha1.RoleReference {Name : "get-invitation-role" , Namespace : "milo-system" }
1181+ acceptRoleRef := iamv1alpha1.RoleReference {Name : "accept-invitation-role" , Namespace : "milo-system" }
1182+
1183+ ui := & iamv1alpha1.UserInvitation {
1184+ ObjectMeta : metav1.ObjectMeta {
1185+ Name : "inv" ,
1186+ Namespace : "default" ,
1187+ UID : types .UID ("ui-uid" ),
1188+ Finalizers : []string {userInvitationFinalizerKey },
1189+ },
1190+ Spec : iamv1alpha1.UserInvitationSpec {
1191+ Email : "test@example.com" ,
1192+ OrganizationRef : resourcemanagerv1alpha1.OrganizationReference {Name : "org" },
1193+ State : iamv1alpha1 .UserInvitationStatePending ,
1194+ },
1195+ }
1196+
1197+ // Pre-create the PolicyBindings that should be GC-ed by the finalizer.
1198+ pbGet := & iamv1alpha1.PolicyBinding {ObjectMeta : metav1.ObjectMeta {
1199+ Name : getDeterministicRoleName (& invitationRoleRef , * ui ),
1200+ Namespace : invitationRoleRef .Namespace ,
1201+ }}
1202+ pbAccept := & iamv1alpha1.PolicyBinding {ObjectMeta : metav1.ObjectMeta {
1203+ Name : getDeterministicRoleName (& acceptRoleRef , * ui ),
1204+ Namespace : acceptRoleRef .Namespace ,
1205+ }}
1206+
1207+ c := fake .NewClientBuilder ().WithScheme (scheme ).
1208+ WithStatusSubresource (& iamv1alpha1.UserInvitation {}).
1209+ WithObjects (ui .DeepCopy (), pbGet , pbAccept ).
1210+ Build ()
1211+
1212+ uic := & UserInvitationController {
1213+ Client : c ,
1214+ SystemNamespace : "milo-system" ,
1215+ uiRelatedRoles : []iamv1alpha1.RoleReference {invitationRoleRef , acceptRoleRef },
1216+ }
1217+ initFinalizer (t , uic )
1218+
1219+ // Mark the UserInvitation for deletion by setting DeletionTimestamp.
1220+ toDelete := & iamv1alpha1.UserInvitation {}
1221+ if err := c .Get (ctx , types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }, toDelete ); err != nil {
1222+ t .Fatalf ("failed to get UserInvitation: %v" , err )
1223+ }
1224+ if err := c .Delete (ctx , toDelete ); err != nil {
1225+ t .Fatalf ("failed to delete UserInvitation: %v" , err )
1226+ }
1227+
1228+ // Reconcile on the deletion event — the finalizer should run and clean up PolicyBindings.
1229+ if _ , err := uic .Reconcile (ctx , ctrl.Request {NamespacedName : types.NamespacedName {Name : ui .Name , Namespace : ui .Namespace }}); err != nil {
1230+ t .Fatalf ("reconcile on deletion returned error: %v" , err )
1231+ }
1232+
1233+ // Both PolicyBindings should now be gone.
1234+ for _ , ref := range []iamv1alpha1.RoleReference {invitationRoleRef , acceptRoleRef } {
1235+ pbName := getDeterministicRoleName (& ref , * ui )
1236+ if err := c .Get (ctx , types.NamespacedName {Name : pbName , Namespace : ref .Namespace }, & iamv1alpha1.PolicyBinding {}); err == nil {
1237+ t .Fatalf ("expected PolicyBinding %s to be deleted by finalizer, but it still exists" , pbName )
1238+ } else if ! apierr .IsNotFound (err ) {
1239+ t .Fatalf ("unexpected error checking PolicyBinding %s: %v" , pbName , err )
1240+ }
1241+ }
1242+ }
0 commit comments