package resources import ( "context" "encoding/json" "reflect" "strings" "testing" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/scim/metadata" "github.com/zitadel/zitadel/internal/api/scim/resources/filter" "github.com/zitadel/zitadel/internal/api/scim/resources/patch" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/test" ) func TestOperationCollection_Apply(t *testing.T) { tests := []struct { name string op *patch.Operation prepare func(user *ScimUser) want *ScimUser wantFn func(user *ScimUser) wantModifications []string wantErr bool }{ { name: "add unknown path", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("fooBar")), Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`), }, wantErr: true, }, { name: "add without path", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`), }, want: &ScimUser{ UserName: "hans.muster", NickName: "hansi", }, }, { name: "add without path (sample from rfc)", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Value: json.RawMessage(` { "emails":[ { "value":"babs@jensen.org", "type":"home", "primary": true } ], "nickname":"Babs" }`), }, want: &ScimUser{ NickName: "Babs", Emails: []*ScimEmail{ { Value: "jeanie.pendleton@example.com", Primary: false, }, { Value: "babs@jensen.org", Primary: true, }, }, }, }, { name: "add with path value which is nil", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("externalid")), Value: json.RawMessage(`"externalid-1"`), }, want: &ScimUser{ ExternalID: "externalid-1", }, wantModifications: []string{"rep:externalid"}, }, { name: "add complex attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("name")), Value: json.RawMessage(`{ "Formatted": "added-formatted", "FamilyName": "added-family-name", "GivenName": "added-given-name", "MiddleName": "added-middle-name", "HonorificPrefix": "added-honorific-prefix", "HonorificSuffix": "added-honorific-suffix" }`), }, want: &ScimUser{ Name: &ScimUserName{ Formatted: "added-formatted", FamilyName: "added-family-name", GivenName: "added-given-name", MiddleName: "added-middle-name", HonorificPrefix: "added-honorific-prefix", HonorificSuffix: "added-honorific-suffix", }, }, wantModifications: []string{"rep:name"}, }, { name: "add complex attribute value", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("name.formatted")), Value: json.RawMessage(`"added-formatted"`), }, want: &ScimUser{ Name: &ScimUserName{ Formatted: "added-formatted", }, }, wantModifications: []string{"rep:name.formatted"}, }, { name: "add single to multi valued empty attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`{ "value": "added-entitlement" }`), }, prepare: func(user *ScimUser) { user.Entitlements = nil }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "added-entitlement", Primary: false, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add single to multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`{ "value": "added-entitlement" }`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "Entitlement 1", Type: "main-entitlement", Primary: true, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, { Value: "added-entitlement", Primary: false, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add single primary to multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`{ "value": "added-entitlement", "primary": true }`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "Entitlement 1", Type: "main-entitlement", Primary: false, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, { Value: "added-entitlement", Primary: true, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add unique valued item in multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(` { "value": "my-entitlement-1", "display": "entitlement-1-patched", "primary": true }`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "entitlement-1-patched", Primary: true, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add unique valued item and additional item in multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`[ { "value": "my-entitlement-1", "display": "entitlement-1-patched", "primary": true }, { "value": "my-entitlement-3", "display": "entitlement-3", "primary": false } ]`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "entitlement-1-patched", Primary: true, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, { Value: "my-entitlement-3", Display: "entitlement-3", Primary: false, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add unique valued items in multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`[ { "value": "my-entitlement-1", "display": "entitlement-1-patched", "primary": true }, { "value": "my-entitlement-2", "display": "entitlement-2-patched", "primary": false } ]`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "entitlement-1-patched", Primary: true, }, { Value: "my-entitlement-2", Display: "entitlement-2-patched", Primary: false, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add multiple to multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(` [ { "value": "added-entitlement", "primary": true }, { "value": "added-entitlement-2" } ]`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "Entitlement 1", Type: "main-entitlement", Primary: false, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, { Value: "added-entitlement", Primary: true, }, { Value: "added-entitlement-2", Primary: false, }, }, }, wantModifications: []string{"add:entitlements"}, }, { name: "add multiple primaries to multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeAdd, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(` [ { "value": "added-entitlement", "primary": true }, { "value": "added-entitlement-2", "primary": true } ]`), }, wantErr: true, }, { name: "remove unknown path", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("fooBar")), }, wantErr: true, }, { name: "remove without a path", op: &patch.Operation{ Operation: patch.OperationTypeRemove, }, wantErr: true, }, { name: "remove single valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeRemove, Path: test.Must(filter.ParsePath("nickname")), }, wantFn: func(user *ScimUser) { assert.Equal(t, user.NickName, "") }, wantModifications: []string{"rem:nickname"}, }, { name: "remove multi valued attribute", op: &patch.Operation{ Operation: patch.OperationTypeRemove, Path: test.Must(filter.ParsePath("entitlements")), }, wantFn: func(user *ScimUser) { assert.Len(t, user.Entitlements, 0) }, wantModifications: []string{"rem:entitlements"}, }, { name: "remove multi valued attribute with filter", op: &patch.Operation{ Operation: patch.OperationTypeRemove, Path: test.Must(filter.ParsePath(`entitlements[display ew "1"]`)), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, }, }, wantModifications: []string{"rem:entitlements"}, }, { name: "remove multi valued attribute with filter matches all", op: &patch.Operation{ Operation: patch.OperationTypeRemove, Path: test.Must(filter.ParsePath(`entitlements[value pr]`)), }, wantFn: func(user *ScimUser) { assert.Len(t, user.Entitlements, 0) }, wantModifications: []string{"rem:entitlements"}, }, { name: "remove attribute of multi valued attribute with filter", op: &patch.Operation{ Operation: patch.OperationTypeRemove, Path: test.Must(filter.ParsePath(`entitlements[display ew "1"].display`)), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "", Type: "main-entitlement", Primary: true, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, }, }, wantModifications: []string{"rem:entitlements.display"}, }, { // this should not fail // according to the rfc only the replace operation fails without a matching target name: "remove multi valued attribute with filter and no matches", op: &patch.Operation{ Operation: patch.OperationTypeRemove, Path: test.Must(filter.ParsePath(`entitlements[display eq "FOOBAR"]`)), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "Entitlement 1", Type: "main-entitlement", Primary: true, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, }, }, }, { name: "replace unknown path", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("fooBar")), Value: json.RawMessage(`"fooBar"`), }, wantErr: true, }, { name: "replace without path", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Value: json.RawMessage(`{ "userName": "hans.muster", "nickname": "hansi" }`), }, want: &ScimUser{ UserName: "hans.muster", NickName: "hansi", }, }, { name: "replace single valued attribute with path", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("nickname")), Value: json.RawMessage(`"fooBar"`), }, want: &ScimUser{ NickName: "fooBar", }, }, { name: "replace with path value which is nil", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("externalid")), Value: json.RawMessage(`"externalid-1"`), }, want: &ScimUser{ ExternalID: "externalid-1", }, wantModifications: []string{"rep:externalid"}, }, { name: "replace complex attribute", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("name")), Value: json.RawMessage(`{ "Formatted": "added-formatted", "FamilyName": "added-family-name", "GivenName": "added-given-name", "MiddleName": "added-middle-name", "HonorificPrefix": "added-honorific-prefix", "HonorificSuffix": "added-honorific-suffix" }`), }, want: &ScimUser{ Name: &ScimUserName{ Formatted: "added-formatted", FamilyName: "added-family-name", GivenName: "added-given-name", MiddleName: "added-middle-name", HonorificPrefix: "added-honorific-prefix", HonorificSuffix: "added-honorific-suffix", }, }, wantModifications: []string{"rep:name"}, }, { name: "replace complex multi attribute", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`{ "value": "entitlement patched" }`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "entitlement patched", }, }, }, wantModifications: []string{"rep:entitlements"}, }, { name: "replace complex multi attribute with multiple", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`[ { "value": "entitlement patched" }, { "value": "entitlement patched2" } ]`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "entitlement patched", }, { Value: "entitlement patched2", }, }, }, wantModifications: []string{"rep:entitlements"}, }, { name: "replace complex multi attribute with multiple primary", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath("entitlements")), Value: json.RawMessage(`[ { "value": "entitlement patched", "primary": true }, { "value": "entitlement patched2", "primary": true } ]`), }, wantErr: true, }, { name: "replace filter no match", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath(`entitlements[value eq "foobar"]`)), }, wantErr: true, }, { name: "replace filter complex subattribute", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath(`entitlements[value eq "my-entitlement-1"].display`)), Value: json.RawMessage(`"updated display"`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "updated display", Type: "main-entitlement", Primary: true, }, }, }, wantModifications: []string{"rep:entitlements.display"}, }, { name: "replace filter complex subattribute primary", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath(`entitlements[value eq "my-entitlement-2"].primary`)), Value: json.RawMessage(`true`), }, want: &ScimUser{ Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "Entitlement 1", Type: "main-entitlement", Primary: false, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: true, }, }, }, wantModifications: []string{"rep:entitlements.primary"}, }, { name: "replace filter complex subattribute multiple primary", op: &patch.Operation{ Operation: patch.OperationTypeReplace, Path: test.Must(filter.ParsePath(`roles[primary ne true].primary`)), Value: json.RawMessage(`true`), }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { user := &ScimUser{ ID: "1", UserName: "username-1", Name: &ScimUserName{ Formatted: "Dr. Jeanie R. Pendleton III", FamilyName: "Pendleton", GivenName: "Jeanie", MiddleName: "Rebecca", HonorificPrefix: "Dr.", HonorificSuffix: "III", }, DisplayName: "Jeanie Pendleton", NickName: "Jenny", Title: "Ms.", ProfileUrl: test.Must(schemas.ParseHTTPURL("https://example.com/profile.gif")), PreferredLanguage: language.MustParse("en-US"), Locale: "en-US", Timezone: "America/New_York", Active: gu.Ptr(true), Emails: []*ScimEmail{ { Value: "jeanie.pendleton@example.com", Primary: true, }, }, PhoneNumbers: []*ScimPhoneNumber{ { Value: "+1 775-599-5252", Primary: true, }, }, Ims: []*ScimIms{ { Value: "jeeeeeny91", Type: "icq", }, }, Addresses: []*ScimAddress{ { Type: "work", StreetAddress: "100 Universal City Plaza", Locality: "Hollywood", Region: "CA", PostalCode: "91608", Country: "USA", Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", Primary: true, }, }, Photos: []*ScimPhoto{ { Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), Type: "photo", }, { Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")), Type: "thumbnail", }, }, Roles: []*ScimRole{ { Value: "my-role-1", Display: "Rolle 1", Type: "main-role", Primary: true, }, { Value: "my-role-2", Display: "Rolle 2", Type: "secondary-role", Primary: false, }, { Value: "my-role-3", Display: "Rolle 3", Type: "third-role", Primary: false, }, }, Entitlements: []*ScimEntitlement{ { Value: "my-entitlement-1", Display: "Entitlement 1", Type: "main-entitlement", Primary: true, }, { Value: "my-entitlement-2", Display: "Entitlement 2", Type: "secondary-entitlement", Primary: false, }, }, } if tt.prepare != nil { tt.prepare(user) } patcher := new(simplePatcher) operations := patch.OperationCollection{tt.op} err := operations.Apply(patcher, user) if tt.wantErr { require.Error(t, err) return } else { require.NoError(t, err) } if !test.PartiallyDeepEqual(tt.want, user) { t.Errorf("apply() got = %#v, want %#v", user, tt.want) } if tt.wantModifications != nil { assert.EqualValues(t, tt.wantModifications, patcher.modifications) } if tt.wantFn != nil { tt.wantFn(user) } }) } } func Test_userPatcher_updateMetadata(t *testing.T) { tests := []struct { name string metadataPath []string metadataChanges map[metadata.Key]*domain.Metadata metadataKeysToRemove map[metadata.Key]bool wantErr bool wantMetadataChanges map[metadata.Key]*domain.Metadata wantMetadataKeysToRemove map[metadata.Key]bool }{ { name: "empty path", }, { name: "unknown attribute", metadataPath: []string{"fooBar"}, }, { name: "unknown nested attribute", metadataPath: []string{"foo", "bar"}, }, { name: "simple attribute", metadataPath: []string{"title"}, wantMetadataChanges: map[metadata.Key]*domain.Metadata{ metadata.KeyTitle: { Key: string(metadata.KeyTitle), Value: []byte("Mr."), }, }, }, { name: "simple attribute with previous deletion", metadataPath: []string{"title"}, metadataKeysToRemove: map[metadata.Key]bool{ metadata.KeyTitle: true, }, wantMetadataChanges: map[metadata.Key]*domain.Metadata{ metadata.KeyTitle: { Key: string(metadata.KeyTitle), Value: []byte("Mr."), }, }, }, { name: "nested attribute", metadataPath: []string{"name", "middlename"}, wantMetadataChanges: map[metadata.Key]*domain.Metadata{ metadata.KeyMiddleName: { Key: string(metadata.KeyMiddleName), Value: []byte("middle name"), }, }, }, { name: "nested attribute with previous deletion", metadataPath: []string{"name", "middlename"}, metadataKeysToRemove: map[metadata.Key]bool{ metadata.KeyMiddleName: true, }, wantMetadataChanges: map[metadata.Key]*domain.Metadata{ metadata.KeyMiddleName: { Key: string(metadata.KeyMiddleName), Value: []byte("middle name"), }, }, }, { name: "complex object root", metadataPath: []string{"name"}, wantMetadataChanges: map[metadata.Key]*domain.Metadata{ metadata.KeyMiddleName: { Key: string(metadata.KeyMiddleName), Value: []byte("middle name"), }, metadata.KeyHonorificPrefix: { Key: string(metadata.KeyHonorificPrefix), Value: []byte("Adel"), }, metadata.KeyHonorificSuffix: { Key: string(metadata.KeyHonorificSuffix), Value: []byte("III"), }, }, }, { name: "delete previous modified attribute", metadataPath: []string{"locale"}, metadataChanges: map[metadata.Key]*domain.Metadata{ metadata.KeyLocale: { Key: string(metadata.KeyLocale), Value: []byte("edited locale"), }, }, wantMetadataKeysToRemove: map[metadata.Key]bool{ metadata.KeyLocale: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &userPatcher{ ctx: context.Background(), user: &ScimUser{ Title: "Mr.", Name: &ScimUserName{ MiddleName: "middle name", HonorificPrefix: "Adel", HonorificSuffix: "III", }, }, metadataChanges: tt.metadataChanges, metadataKeysToRemove: tt.metadataKeysToRemove, } if p.metadataChanges == nil { p.metadataChanges = make(map[metadata.Key]*domain.Metadata) } if p.metadataKeysToRemove == nil { p.metadataKeysToRemove = make(map[metadata.Key]bool) } err := p.updateMetadata(tt.metadataPath) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) if tt.wantMetadataChanges != nil { if !reflect.DeepEqual(p.metadataChanges, tt.wantMetadataChanges) { t.Errorf("updateMetadata() got = %#v, want %#v", p.metadataChanges, tt.wantMetadataChanges) } } if tt.metadataKeysToRemove != nil { if !reflect.DeepEqual(p.metadataKeysToRemove, tt.metadataKeysToRemove) { t.Errorf("updateMetadata() got = %#v, want %#v", p.metadataKeysToRemove, tt.metadataKeysToRemove) } } }) } } type simplePatcher struct { modifications []string } func (s *simplePatcher) FilterEvaluator() *filter.Evaluator { return filter.NewEvaluator(schemas.IdUser) } func (s *simplePatcher) Added(attributePath []string) error { return s.modified("add", attributePath) } func (s *simplePatcher) Replaced(attributePath []string) error { return s.modified("rep", attributePath) } func (s *simplePatcher) Removed(attributePath []string) error { return s.modified("rem", attributePath) } func (s *simplePatcher) modified(op string, attributePath []string) error { s.modifications = append(s.modifications, op+":"+strings.Join(attributePath, ".")) return nil }