//go:build integration package integration_test import ( "context" _ "embed" "encoding/json" "net/http" "path" "reflect" "strings" "testing" "time" "github.com/brianvoe/gofakeit/v6" "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/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" ) var ( //go:embed testdata/bulk_test_full.json bulkFullJson []byte //go:embed testdata/bulk_test_fail_on_errors.json bulkFailOnErrorsJson []byte //go:embed testdata/bulk_test_errors.json bulkErrorsFullJson []byte bulkTooManyOperationsJson []byte ) func init() { bulkFullJson = removeComments(bulkFullJson) bulkFailOnErrorsJson = removeComments(bulkFailOnErrorsJson) bulkErrorsFullJson = removeComments(bulkErrorsFullJson) bulkTooManyOperationsJson = test.Must(json.Marshal(buildTooManyOperationsRequest())) } func TestBulk(t *testing.T) { iamOwnerCtx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) secondaryOrg := Instance.CreateOrganization(iamOwnerCtx, gofakeit.Name(), gofakeit.Email()) createdSecondaryOrgUser := createHumanUser(t, iamOwnerCtx, secondaryOrg.OrganizationId, 0) bulkMinimalUpdateSecondaryOrgJson := test.Must(json.Marshal(buildMinimalUpdateRequest(createdSecondaryOrgUser.UserId))) membershipNotFoundErr := &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "membership not found", Status: "404", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "AUTHZ-cdgFk", Message: "membership not found", }, } type wantErr struct { scimErrorType string status int zitadelErrID string } tests := []struct { name string body []byte ctx context.Context orgID string want *scim.BulkResponse wantErr *wantErr wantUsers map[string]*resources.ScimUser }{ { name: "not authenticated", body: bulkFullJson, ctx: context.Background(), wantErr: &wantErr{ status: http.StatusUnauthorized, }, }, { name: "no permissions", body: bulkFullJson, ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ { Method: http.MethodPost, Response: membershipNotFoundErr, Status: "404", }, { Method: http.MethodPost, BulkID: "1", Response: membershipNotFoundErr, Status: "404", }, { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "Could not resolve bulkID 1 to created ID", Status: "400", ScimType: "invalidValue", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-BLK4", Message: "Could not resolve bulkID 1 to created ID", }, }, Status: "400", }, { Method: http.MethodPost, BulkID: "2", Response: membershipNotFoundErr, Status: "404", }, { Method: http.MethodPut, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "Could not resolve bulkID 2 to created ID", Status: "400", ScimType: "invalidValue", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-BLK4", Message: "Could not resolve bulkID 2 to created ID", }, }, Status: "400", }, { Method: http.MethodPost, BulkID: "3", Response: membershipNotFoundErr, Status: "404", }, { Method: http.MethodDelete, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "Could not resolve bulkID 3 to created ID", Status: "400", ScimType: "invalidValue", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-BLK4", Message: "Could not resolve bulkID 3 to created ID", }, }, Status: "400", }, { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "User could not be found", Status: "404", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "COMMAND-ugjs0upun6", Message: "Errors.User.NotFound", }, }, Status: "404", }, { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "Could not resolve bulkID 99 to created ID", Status: "400", ScimType: "invalidValue", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-BLK4", Message: "Could not resolve bulkID 99 to created ID", }, }, Status: "400", }, }, }, }, { name: "full", body: bulkFullJson, want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ { Method: http.MethodPost, Status: "201", }, { Method: http.MethodPost, BulkID: "1", Status: "201", }, { Method: http.MethodPatch, Status: "204", }, { Method: http.MethodPost, BulkID: "2", Status: "201", }, { Method: http.MethodPut, Status: "200", }, { Method: http.MethodPost, BulkID: "3", Status: "201", }, { Method: http.MethodDelete, Status: "204", }, { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "User could not be found", Status: "404", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "COMMAND-ugjs0upun6", Message: "Errors.User.NotFound", }, }, Status: "404", }, { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "Could not resolve bulkID 99 to created ID", Status: "400", ScimType: "invalidValue", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-BLK4", Message: "Could not resolve bulkID 99 to created ID", }, }, Status: "400", }, }, }, wantUsers: map[string]*resources.ScimUser{ "scim-bulk-created-user-0": { ExternalID: "scim-bulk-created-user-0", UserName: "scim-bulk-created-user-0", Name: &resources.ScimUserName{ Formatted: "scim-bulk-created-user-0-given-name scim-bulk-created-user-0-family-name", FamilyName: "scim-bulk-created-user-0-family-name", GivenName: "scim-bulk-created-user-0-given-name", }, DisplayName: "scim-bulk-created-user-0-given-name scim-bulk-created-user-0-family-name", PreferredLanguage: test.Must(language.Parse("en")), Active: gu.Ptr(true), Emails: []*resources.ScimEmail{ { Value: "scim-bulk-created-user-0@example.com", Primary: true, }, }, }, "scim-bulk-created-user-1": { ExternalID: "scim-bulk-created-user-1", UserName: "scim-bulk-created-user-1", Name: &resources.ScimUserName{ Formatted: "scim-bulk-created-user-1-given-name scim-bulk-created-user-1-family-name", FamilyName: "scim-bulk-created-user-1-family-name", GivenName: "scim-bulk-created-user-1-given-name", }, DisplayName: "scim-bulk-created-user-1-given-name scim-bulk-created-user-1-family-name", NickName: "scim-bulk-created-user-1-nickname-patched", PreferredLanguage: test.Must(language.Parse("en")), Active: gu.Ptr(true), Emails: []*resources.ScimEmail{ { Value: "scim-bulk-created-user-1@example.com", Primary: true, }, }, PhoneNumbers: []*resources.ScimPhoneNumber{ { Value: "+41711231212", Primary: true, }, }, }, "scim-bulk-created-user-2": { ExternalID: "scim-bulk-created-user-2", UserName: "scim-bulk-created-user-2", Name: &resources.ScimUserName{ Formatted: "scim-bulk-created-user-2-given-name scim-bulk-created-user-2-family-name", FamilyName: "scim-bulk-created-user-2-family-name", GivenName: "scim-bulk-created-user-2-given-name", }, DisplayName: "scim-bulk-created-user-2-given-name scim-bulk-created-user-2-family-name", NickName: "scim-bulk-created-user-2-nickname-patched", PreferredLanguage: test.Must(language.Parse("en")), Active: gu.Ptr(true), Emails: []*resources.ScimEmail{ { Value: "scim-bulk-created-user-2@example.com", Primary: true, }, }, PhoneNumbers: []*resources.ScimPhoneNumber{ { Value: "+41711231212", Primary: true, }, }, }, }, }, { name: "errors", body: bulkErrorsFullJson, want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "User could not be found", Status: "404", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "COMMAND-ugjs0upun6", Message: "Errors.User.NotFound", }, }, Status: "404", }, { Method: http.MethodPost, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Email is empty", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-EM19", Message: "Errors.User.Email.Empty", }, }, Status: "400", }, { Method: http.MethodPost, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Could not parse locale", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-MD11", Message: "Could not parse locale", }, }, Status: "400", }, { Method: http.MethodPost, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Password is too short", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "COMMA-HuJf6", Message: "Errors.User.PasswordComplexityPolicy.MinLength", }, }, Status: "400", }, { Method: "POST", Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Could not parse timezone", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-MD12", Message: "Could not parse timezone", }, }, Status: "400", }, { Method: http.MethodPost, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Errors.Invalid.Argument", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "V2-zzad3", Message: "Errors.Invalid.Argument", }, }, Status: "400", }, { Method: "POST", Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Given name in profile is empty", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "USER-UCej2", Message: "Errors.User.Profile.FirstNameEmpty", }, }, Status: "400", }, }, }, }, { name: "fail on errors", body: bulkFailOnErrorsJson, want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ { Method: http.MethodPatch, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, Detail: "User could not be found", Status: "404", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "COMMAND-ugjs0upun6", Message: "Errors.User.NotFound", }, }, Status: "404", }, { Method: http.MethodPost, Status: "201", }, { Method: http.MethodPost, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Email is empty", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-EM19", Message: "Errors.User.Email.Empty", }, }, Status: "400", }, { Method: http.MethodPost, Response: &scim.ScimError{ Schemas: []string{ "urn:ietf:params:scim:api:messages:2.0:Error", "urn:ietf:params:scim:api:zitadel:messages:2.0:ErrorDetail", }, ScimType: "invalidValue", Detail: "Could not parse locale", Status: "400", ZitadelDetail: &scim.ZitadelErrorDetail{ ID: "SCIM-MD11", Message: "Could not parse locale", }, }, Status: "400", }, }, }, }, { name: "too many operations", body: bulkTooManyOperationsJson, wantErr: &wantErr{ status: http.StatusRequestEntityTooLarge, scimErrorType: "invalidValue", zitadelErrID: "SCIM-BLK19", }, }, { name: "another organization", body: bulkMinimalUpdateSecondaryOrgJson, orgID: secondaryOrg.OrganizationId, want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ { Method: http.MethodPatch, Response: membershipNotFoundErr, Status: "404", }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := tt.ctx if ctx == nil { ctx = CTX } orgID := tt.orgID if orgID == "" { orgID = Instance.DefaultOrg.Id } response, err := Instance.Client.SCIM.Bulk(ctx, orgID, tt.body) createdUserIDs := buildCreatedIDs(response) defer deleteUsers(t, createdUserIDs) if tt.wantErr != nil { statusCode := tt.wantErr.status if statusCode == 0 { statusCode = http.StatusBadRequest } scimErr := scim.RequireScimError(t, statusCode, err) assert.Equal(t, tt.wantErr.scimErrorType, scimErr.Error.ScimType) if tt.wantErr.zitadelErrID != "" { assert.Equal(t, tt.wantErr.zitadelErrID, scimErr.Error.ZitadelDetail.ID) } return } require.NoError(t, err) require.EqualValues(t, []schemas.ScimSchemaType{schemas.IdBulkResponse}, response.Schemas) locationPrefix := "http://" + Instance.Host() + path.Join(schemas.HandlerPrefix, orgID, "Users") + "/" for _, responseOperation := range response.Operations { // POST operations which result in an error don't expect a location if responseOperation.Method == http.MethodPost && responseOperation.Response != nil { require.Empty(t, responseOperation.Location) } else { require.True(t, strings.HasPrefix(responseOperation.Location, locationPrefix)) } // don't assert the location in the deep equal responseOperation.Location = "" } if !reflect.DeepEqual(tt.want, response) { x := test.Must(json.Marshal(tt.want)) x2 := test.Must(json.Marshal(response)) t.Errorf("want: %v, got: %v", x, x2) t.Errorf("want: %#v, got: %#v", tt.want, response) } if tt.wantUsers != nil { for _, createdUserID := range createdUserIDs { retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { user, err := Instance.Client.SCIM.Users.Get(ctx, orgID, createdUserID) if err != nil { scim.RequireScimError(ttt, http.StatusNotFound, err) return } wantUser, ok := tt.wantUsers[user.UserName] if !ok { return } if !test.PartiallyDeepEqual(wantUser, user) { ttt.Errorf("want: %#v, got: %#v", wantUser, user) } }, retryDuration, tick) } } }) } } func buildCreatedIDs(response *scim.BulkResponse) []string { createdIds := make([]string, 0, len(response.Operations)) for _, operation := range response.Operations { if operation.Method == http.MethodPost && operation.Status == "201" { parts := strings.Split(operation.Location, "/") createdIds = append(createdIds, parts[len(parts)-1]) } } return createdIds } func deleteUsers(t require.TestingT, ids []string) { for _, id := range ids { err := Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, id) // only not found errors are ok (if the user is deleted in a later on bulk request) if err != nil { scim.RequireScimError(t, http.StatusNotFound, err) } } } func buildMinimalUpdateRequest(userID string) *scim.BulkRequest { return &scim.BulkRequest{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkRequest}, Operations: []*scim.BulkRequestOperation{ { Method: http.MethodPatch, Path: "/Users/" + userID, Data: simpleReplacePatchBody("nickname", `"foo-bar-nickname"`), }, }, } } func buildTooManyOperationsRequest() *scim.BulkRequest { req := &scim.BulkRequest{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkRequest}, Operations: make([]*scim.BulkRequestOperation, 101), // default config (100) + 1, see defaults.yaml } for i := 0; i < len(req.Operations); i++ { req.Operations[i] = &scim.BulkRequestOperation{ Method: http.MethodPost, Path: "/Users", Data: minimalUserJson, } } return req }