From a7bbcbabe156663dbe34b0220eba89c45184fce0 Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:10:00 +0000 Subject: [PATCH 1/6] Add `FailOnEmpty` option to input --- input/v1beta1/input.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 41ccefc..4ed48fa 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -67,6 +67,12 @@ type Input struct { // +optional SkipQueryWhenTargetHasData *bool `json:"skipQueryWhenTargetHasData,omitempty"` + // FailOnEmpty controls whether the function should fail when input lists are empty. + // If true, the function will error on empty input lists. + // If false or unset, empty lists are valid and will result in a no-op. + // +optional + FailOnEmpty *bool `json:"failOnEmpty,omitempty"` + // Identity defines the type of identity used for authentication to the Microsoft Graph API. Identity *Identity `json:"identity,omitempty"` } From 34c44fbb35242180fb6c3b61ee8ec3cbf5fb2bfd Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:11:14 +0000 Subject: [PATCH 2/6] Add fail on empty option to error condition --- fn.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/fn.go b/fn.go index c34cfe6..a51113a 100644 --- a/fn.go +++ b/fn.go @@ -497,7 +497,7 @@ func (g *GraphQuery) graphQuery(ctx context.Context, azureCreds map[string]strin // validateUsers validates if the provided user principal names (emails) exist func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.Users) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Users) == 0 { return nil, errors.New("no users provided for validation") } @@ -754,7 +754,7 @@ func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.Gra // getGroupObjectIDs retrieves object IDs for the specified group names func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.Groups) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Groups) == 0 { return nil, errors.New("no group names provided") } @@ -799,7 +799,7 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G // getServicePrincipalDetails retrieves details about service principals by name func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.ServicePrincipals) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.ServicePrincipals) == 0 { return nil, errors.New("no service principal names provided") } @@ -1515,10 +1515,8 @@ func (f *Function) extractStringArrayFromMap(dataMap map[string]interface{}, fie result = append(result, &strCopy) } } - if len(result) > 0 { - return result, nil - } + return result, nil } - return nil, errors.Errorf("cannot resolve groupsRef: %s not a string array or empty", refKey) + return nil, errors.Errorf("cannot resolve groupsRef: %s not a string array", refKey) } From 1df089eb8af2d54eba69c0edc50e9c1684fb5b3e Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:38:10 +0100 Subject: [PATCH 3/6] Initialise empty results slice With this change, if no groups are queried, an empty list is returned instead of null. Signed-off-by: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> --- fn.go | 6 +++--- input/v1beta1/input.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fn.go b/fn.go index a51113a..7bd326c 100644 --- a/fn.go +++ b/fn.go @@ -501,7 +501,7 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph return nil, errors.New("no users provided for validation") } - var results []interface{} + results := make([]interface{}, 0) for _, userPrincipalName := range in.Users { if userPrincipalName == nil { @@ -758,7 +758,7 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G return nil, errors.New("no group names provided") } - var results []interface{} + results := make([]interface{}, 0) for _, groupName := range in.Groups { if groupName == nil { @@ -803,7 +803,7 @@ func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msg return nil, errors.New("no service principal names provided") } - var results []interface{} + results := make([]interface{}, 0) for _, spName := range in.ServicePrincipals { if spName == nil { diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 4ed48fa..aa659ed 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -69,7 +69,7 @@ type Input struct { // FailOnEmpty controls whether the function should fail when input lists are empty. // If true, the function will error on empty input lists. - // If false or unset, empty lists are valid and will result in a no-op. + // If false or unset, empty lists are valid and will result in an empty list at the target. // +optional FailOnEmpty *bool `json:"failOnEmpty,omitempty"` From 8ec511c4bfe5c7573fdbd39f4ad34e34f3aaf669 Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:46:14 +0100 Subject: [PATCH 4/6] Add empty cases for ref resolution tests Also updates the mock to contain the same logic as added in the real implementation. Signed-off-by: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> --- fn_test.go | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 213 insertions(+), 6 deletions(-) diff --git a/fn_test.go b/fn_test.go index 0aec207..d28f71e 100644 --- a/fn_test.go +++ b/fn_test.go @@ -311,6 +311,75 @@ func TestResolveGroupsRef(t *testing.T) { }, }, }, + "GroupsRefEmpty": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + }, + "status": { + "groupObjectIDs": [] + }}`), + }, + }, + }, + }, + }, "GroupsRefNotFound": { reason: "The Function should handle an error when groupsRef cannot be resolved", args: args{ @@ -371,11 +440,11 @@ func TestResolveGroupsRef(t *testing.T) { mockQuery := &MockGraphQuery{ GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { if in.QueryType == "GroupObjectIDs" { - if len(in.Groups) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Groups) == 0 { return nil, errors.New("no group names provided") } - var results []interface{} + results := make([]interface{}, 0) for i, group := range in.Groups { if group == nil { continue @@ -1089,6 +1158,75 @@ func TestResolveUsersRef(t *testing.T) { }, }, }, + "UsersRefEmpty": { + reason: "The Function should resolve usersRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "spec.userAccess.emails", + "target": "status.validatedUsers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + }, + "status": { + "validatedUsers": [] + }}`), + }, + }, + }, + }, + }, "UsersRefNotFound": { reason: "The Function should handle an error when usersRef cannot be resolved", args: args{ @@ -1149,11 +1287,11 @@ func TestResolveUsersRef(t *testing.T) { mockQuery := &MockGraphQuery{ GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { if in.QueryType == "UserValidation" { - if len(in.Users) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Users) == 0 { return nil, errors.New("no users provided for validation") } - var results []interface{} + results := make([]interface{}, 0) for _, user := range in.Users { if user == nil { continue @@ -1498,6 +1636,75 @@ func TestResolveServicePrincipalsRef(t *testing.T) { }, }, }, + "ServicePrincipalsRefEmpty": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + }, + "status": { + "servicePrincipals": [] + }}`), + }, + }, + }, + }, + }, "ServicePrincipalsRefNotFound": { reason: "The Function should handle an error when servicePrincipalsRef cannot be resolved", args: args{ @@ -1558,11 +1765,11 @@ func TestResolveServicePrincipalsRef(t *testing.T) { mockQuery := &MockGraphQuery{ GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { if in.QueryType == "ServicePrincipalDetails" { - if len(in.ServicePrincipals) == 0 { + if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.ServicePrincipals) == 0 { return nil, errors.New("no service principal names provided") } - var results []interface{} + results := make([]interface{}, 0) for i, sp := range in.ServicePrincipals { if sp == nil { continue From c0db281f427c93741d647089cc7528a4b1ae4282 Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:14:54 +0100 Subject: [PATCH 5/6] Add test cases for `failOnEmpty` permutations Signed-off-by: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> --- fn_test.go | 396 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 393 insertions(+), 3 deletions(-) diff --git a/fn_test.go b/fn_test.go index d28f71e..983d6cf 100644 --- a/fn_test.go +++ b/fn_test.go @@ -311,7 +311,7 @@ func TestResolveGroupsRef(t *testing.T) { }, }, }, - "GroupsRefEmpty": { + "GroupsRefEmptyDefault": { reason: "The Function should resolve groupsRef from XR spec", args: args{ ctx: context.Background(), @@ -380,6 +380,136 @@ func TestResolveGroupsRef(t *testing.T) { }, }, }, + "GroupsRefEmptyNoFail": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs", + "failOnEmpty": false + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + }, + "status": { + "groupObjectIDs": [] + }}`), + }, + }, + }, + }, + }, + "GroupsRefEmptyFail": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs", + "failOnEmpty": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "no group names provided", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": [] + } + }}`), + }, + }, + }, + }, + }, "GroupsRefNotFound": { reason: "The Function should handle an error when groupsRef cannot be resolved", args: args{ @@ -1158,7 +1288,7 @@ func TestResolveUsersRef(t *testing.T) { }, }, }, - "UsersRefEmpty": { + "UsersRefEmptyDefault": { reason: "The Function should resolve usersRef from XR spec", args: args{ ctx: context.Background(), @@ -1227,6 +1357,136 @@ func TestResolveUsersRef(t *testing.T) { }, }, }, + "UsersRefEmptyNoFail": { + reason: "The Function should resolve usersRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "spec.userAccess.emails", + "target": "status.validatedUsers", + "failOnEmpty": false + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + }, + "status": { + "validatedUsers": [] + }}`), + }, + }, + }, + }, + }, + "UsersRefEmptyFail": { + reason: "The Function should resolve usersRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "spec.userAccess.emails", + "target": "status.validatedUsers", + "failOnEmpty": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "no users provided for validation", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": [] + } + }}`), + }, + }, + }, + }, + }, "UsersRefNotFound": { reason: "The Function should handle an error when usersRef cannot be resolved", args: args{ @@ -1636,7 +1896,7 @@ func TestResolveServicePrincipalsRef(t *testing.T) { }, }, }, - "ServicePrincipalsRefEmpty": { + "ServicePrincipalsRefEmptyDefault": { reason: "The Function should resolve servicePrincipalsRef from XR spec", args: args{ ctx: context.Background(), @@ -1705,6 +1965,136 @@ func TestResolveServicePrincipalsRef(t *testing.T) { }, }, }, + "ServicePrincipalsRefEmptyNoFail": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals", + "failOnEmpty": false + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + }, + "status": { + "servicePrincipals": [] + }}`), + }, + }, + }, + }, + }, + "ServicePrincipalsRefEmptyFail": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals", + "failOnEmpty": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "no service principal names provided", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": [] + } + }}`), + }, + }, + }, + }, + }, "ServicePrincipalsRefNotFound": { reason: "The Function should handle an error when servicePrincipalsRef cannot be resolved", args: args{ From 560fc319ba0ad8d7bcf5b11b4496be36490c4a90 Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:01:10 +0100 Subject: [PATCH 6/6] Add FailOnEmpty option to function configuration options in README Signed-off-by: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7bede2c..4a7771c 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,8 @@ spec: | `servicePrincipalsRef` | string | Reference to resolve a list of service principal names from `spec`, `status` or `context` (e.g., `spec.servicePrincipalConfig.names`) | | `target` | string | Required. Where to store the query results. Can be `status.` or `context.` | | `skipQueryWhenTargetHasData` | bool | Optional. When true, will skip the query if the target already has data | -| `identity.type | string | Optional. Type of identity credentials to use. Valid values: `AzureServicePrincipalCredentials`, `AzureWorkloadIdentityCredentials`. Default is `AzureServicePrincipalCredentials` | +| `FailOnEmpty` | bool | Optional. When true, the function will fail if the `users`, `groups`, or `servicePrincipals` lists are empty, or if their respective reference fields are empty lists. | +| `identity.type` | string | Optional. Type of identity credentials to use. Valid values: `AzureServicePrincipalCredentials`, `AzureWorkloadIdentityCredentials`. Default is `AzureServicePrincipalCredentials` | ## Result Targets