diff --git a/cmd/start/start.go b/cmd/start/start.go index 5fc4ba936a..68c5bdb915 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -454,7 +454,7 @@ func startAPIs( if err := apis.RegisterService(ctx, userschema_v3_alpha.CreateServer(config.SystemDefaults, commands, queries)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands, keys.User)); err != nil { + if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands)); err != nil { return nil, err } if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { diff --git a/internal/api/grpc/resources/user/v3alpha/email.go b/internal/api/grpc/resources/user/v3alpha/email.go new file mode 100644 index 0000000000..7b0b561cd9 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/email.go @@ -0,0 +1,83 @@ +package user + +import ( + "context" + + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) SetContactEmail(ctx context.Context, req *user.SetContactEmailRequest) (_ *user.SetContactEmailResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := setContactEmailRequestToChangeSchemaUserEmail(req) + details, err := s.command.ChangeSchemaUserEmail(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.SetContactEmailResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.ReturnCode, + }, nil +} + +func setContactEmailRequestToChangeSchemaUserEmail(req *user.SetContactEmailRequest) *command.ChangeSchemaUserEmail { + return &command.ChangeSchemaUserEmail{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + Email: setEmailToEmail(req.Email), + } +} + +func setEmailToEmail(setEmail *user.SetEmail) *command.Email { + if setEmail == nil { + return nil + } + return &command.Email{ + Address: domain.EmailAddress(setEmail.Address), + ReturnCode: setEmail.GetReturnCode() != nil, + Verified: setEmail.GetIsVerified(), + URLTemplate: setEmail.GetSendCode().GetUrlTemplate(), + } +} + +func (s *Server) VerifyContactEmail(ctx context.Context, req *user.VerifyContactEmailRequest) (_ *user.VerifyContactEmailResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.VerifySchemaUserEmail(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId(), req.GetVerificationCode()) + if err != nil { + return nil, err + } + return &user.VerifyContactEmailResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) ResendContactEmailCode(ctx context.Context, req *user.ResendContactEmailCodeRequest) (_ *user.ResendContactEmailCodeResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := resendContactEmailCodeRequestToResendSchemaUserEmailCode(req) + details, err := s.command.ResendSchemaUserEmailCode(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.ResendContactEmailCodeResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.PlainCode, + }, nil +} + +func resendContactEmailCodeRequestToResendSchemaUserEmailCode(req *user.ResendContactEmailCodeRequest) *command.ResendSchemaUserEmailCode { + return &command.ResendSchemaUserEmailCode{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + URLTemplate: req.GetSendCode().GetUrlTemplate(), + ReturnCode: req.GetReturnCode() != nil, + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go new file mode 100644 index 0000000000..c5bec9008e --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/email_test.go @@ -0,0 +1,772 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func TestServer_SetContactEmail(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.SetContactEmailRequest) error + req *user.SetContactEmailRequest + res res + wantErr bool + }{ + { + name: "email patch, no context", + ctx: context.Background(), + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + wantErr: true, + }, + { + name: "email patch, empty", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + email := gofakeit.Email() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, email) + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{}, + }, + wantErr: true, + }, + { + name: "email patch, no change", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + email := gofakeit.Email() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, email) + req.Email.Address = email + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Email: &user.SetEmail{ + Address: gofakeit.Email(), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_ReturnCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "email patch, return, invalid template", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_SendCode{SendCode: &user.SendEmailVerificationCode{UrlTemplate: gu.Ptr("{{")}}, + }, + }, + wantErr: true, + }, + { + name: "email patch, verified, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_IsVerified{IsVerified: true}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, template, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_SendCode{SendCode: &user.SendEmailVerificationCode{UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}")}}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email patch, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_SendCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.SetContactEmail(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} + +func TestServer_VerifyContactEmail(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.VerifyContactEmailRequest) error + req *user.VerifyContactEmailRequest + res res + wantErr bool + }{ + { + name: "email verify, no context", + ctx: context.Background(), + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email verify, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email verify, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + VerificationCode: "unimportant", + }, + wantErr: true, + }, + { + name: "email verify, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "email verify, wrong code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + VerificationCode: "wrong", + }, + wantErr: true, + }, + { + name: "email verify, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email verify, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactEmailRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactEmailRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.VerifyContactEmail(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + }) + } +} + +func TestServer_ResendContactEmailCode(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.ResendContactEmailCodeRequest) error + req *user.ResendContactEmailCodeRequest + res res + wantErr bool + }{ + { + name: "email resend, no context", + ctx: context.Background(), + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "email resend, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, no code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "email resend, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "email resend, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactEmailCodeRequest_ReturnCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "email resend, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactEmailCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserEmail(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Email()) + return nil + }, + req: &user.ResendContactEmailCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactEmailCodeRequest_SendCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.ResendContactEmailCode(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go new file mode 100644 index 0000000000..d61135d30d --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/phone_test.go @@ -0,0 +1,701 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func TestServer_SetContactPhone(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.SetContactPhoneRequest) error + req *user.SetContactPhoneRequest + res res + wantErr bool + }{ + { + name: "phone patch, no context", + ctx: context.Background(), + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + wantErr: true, + }, + { + name: "phone patch, no change", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + number := gofakeit.Phone() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, number) + req.Phone.Number = number + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone patch, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone patch, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_ReturnCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "phone patch, verified, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_IsVerified{IsVerified: true}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone patch, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.SetContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.SetContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_SendCode{}, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.SetContactPhone(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} + +func TestServer_VerifyContactPhone(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCodePhone bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.VerifyContactPhoneRequest) error + req *user.VerifyContactPhoneRequest + res res + wantErr bool + }{ + { + name: "phone verify, no context", + ctx: context.Background(), + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone verify, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone verify, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + VerificationCode: "unimportant", + }, + wantErr: true, + }, + { + name: "phone verify, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "phone verify, wrong code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + VerificationCode: "wrong", + }, + wantErr: true, + }, + { + name: "phone verify, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone verify, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.VerifyContactPhoneRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + verifyResp := instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + req.VerificationCode = verifyResp.GetVerificationCode() + return nil + }, + req: &user.VerifyContactPhoneRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCodePhone: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.VerifyContactPhone(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + }) + } +} + +func TestServer_ResendContactPhoneCode(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCode bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.ResendContactPhoneCodeRequest) error + req *user.ResendContactPhoneCodeRequest + res res + wantErr bool + }{ + { + name: "phone resend, no context", + ctx: context.Background(), + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "phone resend, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, no code", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "phone resend, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{}, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "phone resend, return, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactPhoneCodeRequest_ReturnCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCode: true, + }, + }, + { + name: "phone resend, sent, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ResendContactPhoneCodeRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.UpdateSchemaUserPhone(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id, gofakeit.Phone()) + return nil + }, + req: &user.ResendContactPhoneCodeRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Verification: &user.ResendContactPhoneCodeRequest_SendCode{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.ResendContactPhoneCode(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCode { + assert.NotNil(t, got.VerificationCode) + } else { + assert.Nil(t, got.VerificationCode) + } + }) + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go index b2b11fd510..fee1a38430 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" ) var ( @@ -51,7 +52,7 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) - require.NoError(ttt, err) + assert.NoError(ttt, err) if f.UserSchema.GetEnabled() { return } @@ -59,4 +60,13 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { retryDuration, time.Second, "timed out waiting for ensuring instance feature") + + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + _, err := instance.Client.UserV3Alpha.SearchUsers(ctx, &user.SearchUsersRequest{}) + assert.NoError(ttt, err) + }, + retryDuration, + time.Second, + "timed out waiting for ensuring instance feature call") } diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go index c8db0f7f6a..95e98b1a9e 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go @@ -8,6 +8,7 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/logging" "google.golang.org/protobuf/types/known/structpb" @@ -628,16 +629,20 @@ func TestServer_PatchUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.PatchUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.res.want, got.Details) if tt.res.returnCodeEmail { - require.NotNil(t, got.EmailCode) + assert.NotNil(t, got.EmailCode) + } else { + assert.Nil(t, got.EmailCode) } if tt.res.returnCodePhone { - require.NotNil(t, got.PhoneCode) + assert.NotNil(t, got.PhoneCode) + } else { + assert.Nil(t, got.PhoneCode) } }) } @@ -843,10 +848,10 @@ func TestServer_DeleteUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.DeleteUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1054,10 +1059,10 @@ func TestServer_LockUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.LockUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1237,10 +1242,10 @@ func TestServer_UnlockUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.UnlockUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1439,10 +1444,10 @@ func TestServer_DeactivateUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.DeactivateUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } @@ -1622,10 +1627,10 @@ func TestServer_ActivateUser(t *testing.T) { } got, err := instance.Client.UserV3Alpha.ActivateUser(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return } - require.NoError(t, err) + assert.NoError(t, err) integration.AssertResourceDetails(t, tt.want, got.Details) }) } diff --git a/internal/api/grpc/resources/user/v3alpha/phone.go b/internal/api/grpc/resources/user/v3alpha/phone.go new file mode 100644 index 0000000000..64cab1c13b --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/phone.go @@ -0,0 +1,81 @@ +package user + +import ( + "context" + + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) SetContactPhone(ctx context.Context, req *user.SetContactPhoneRequest) (_ *user.SetContactPhoneResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := setContactPhoneRequestToChangeSchemaUserPhone(req) + details, err := s.command.ChangeSchemaUserPhone(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.SetContactPhoneResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.ReturnCode, + }, nil +} + +func setContactPhoneRequestToChangeSchemaUserPhone(req *user.SetContactPhoneRequest) *command.ChangeSchemaUserPhone { + return &command.ChangeSchemaUserPhone{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + Phone: setPhoneToPhone(req.Phone), + } +} + +func setPhoneToPhone(setPhone *user.SetPhone) *command.Phone { + if setPhone == nil { + return nil + } + return &command.Phone{ + Number: domain.PhoneNumber(setPhone.Number), + ReturnCode: setPhone.GetReturnCode() != nil, + Verified: setPhone.GetIsVerified(), + } +} + +func (s *Server) VerifyContactPhone(ctx context.Context, req *user.VerifyContactPhoneRequest) (_ *user.VerifyContactPhoneResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + details, err := s.command.VerifySchemaUserPhone(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId(), req.GetVerificationCode()) + if err != nil { + return nil, err + } + return &user.VerifyContactPhoneResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) ResendContactPhoneCode(ctx context.Context, req *user.ResendContactPhoneCodeRequest) (_ *user.ResendContactPhoneCodeResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser := resendContactPhoneCodeRequestToResendSchemaUserPhoneCode(req) + details, err := s.command.ResendSchemaUserPhoneCode(ctx, schemauser) + if err != nil { + return nil, err + } + return &user.ResendContactPhoneCodeResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + VerificationCode: schemauser.PlainCode, + }, nil +} + +func resendContactPhoneCodeRequestToResendSchemaUserPhoneCode(req *user.ResendContactPhoneCodeRequest) *command.ResendSchemaUserPhoneCode { + return &command.ResendSchemaUserPhoneCode{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + ReturnCode: req.GetReturnCode() != nil, + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/query.go b/internal/api/grpc/resources/user/v3alpha/query.go new file mode 100644 index 0000000000..802ad3fdc3 --- /dev/null +++ b/internal/api/grpc/resources/user/v3alpha/query.go @@ -0,0 +1,14 @@ +package user + +import ( + "context" + + user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" +) + +func (s *Server) SearchUsers(ctx context.Context, _ *user.SearchUsersRequest) (_ *user.SearchUsersResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + return &user.SearchUsersResponse{}, nil +} diff --git a/internal/api/grpc/resources/user/v3alpha/server.go b/internal/api/grpc/resources/user/v3alpha/server.go index e18f017453..57b2e44016 100644 --- a/internal/api/grpc/resources/user/v3alpha/server.go +++ b/internal/api/grpc/resources/user/v3alpha/server.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/crypto" user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" ) @@ -14,19 +13,16 @@ var _ user.ZITADELUsersServer = (*Server)(nil) type Server struct { user.UnimplementedZITADELUsersServer - command *command.Commands - userCodeAlg crypto.EncryptionAlgorithm + command *command.Commands } type Config struct{} func CreateServer( command *command.Commands, - userCodeAlg crypto.EncryptionAlgorithm, ) *Server { return &Server{ - command: command, - userCodeAlg: userCodeAlg, + command: command, } } diff --git a/internal/api/grpc/resources/user/v3alpha/user.go b/internal/api/grpc/resources/user/v3alpha/user.go index 7c3f2a750e..971644de7d 100644 --- a/internal/api/grpc/resources/user/v3alpha/user.go +++ b/internal/api/grpc/resources/user/v3alpha/user.go @@ -3,12 +3,9 @@ package user import ( "context" - "github.com/muhlemmer/gu" - "github.com/zitadel/zitadel/internal/api/authz" resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" @@ -22,14 +19,14 @@ func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (_ if err != nil { return nil, err } - - if err := s.command.CreateSchemaUser(ctx, schemauser, s.userCodeAlg); err != nil { + details, err := s.command.CreateSchemaUser(ctx, schemauser) + if err != nil { return nil, err } return &user.CreateUserResponse{ - Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.Details.ResourceOwner), - EmailCode: gu.Ptr(schemauser.ReturnCodeEmail), - PhoneCode: gu.Ptr(schemauser.ReturnCodePhone), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + EmailCode: schemauser.ReturnCodeEmail, + PhoneCode: schemauser.ReturnCodePhone, }, nil } @@ -44,6 +41,8 @@ func createUserRequestToCreateSchemaUser(ctx context.Context, req *user.CreateUs SchemaID: req.GetUser().GetSchemaId(), ID: req.GetUser().GetUserId(), Data: data, + Email: setEmailToEmail(req.GetUser().GetContact().GetEmail()), + Phone: setPhoneToPhone(req.GetUser().GetContact().GetPhone()), }, nil } @@ -91,17 +90,36 @@ func (s *Server) PatchUser(ctx context.Context, req *user.PatchUserRequest) (_ * return nil, err } - if err := s.command.ChangeSchemaUser(ctx, schemauser, s.userCodeAlg); err != nil { + details, err := s.command.ChangeSchemaUser(ctx, schemauser) + if err != nil { return nil, err } return &user.PatchUserResponse{ - Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.Details.ResourceOwner), - EmailCode: gu.Ptr(schemauser.ReturnCodeEmail), - PhoneCode: gu.Ptr(schemauser.ReturnCodePhone), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + EmailCode: schemauser.ReturnCodeEmail, + PhoneCode: schemauser.ReturnCodePhone, }, nil } func patchUserRequestToChangeSchemaUser(req *user.PatchUserRequest) (_ *command.ChangeSchemaUser, err error) { + schemaUser, err := setSchemaUserToSchemaUser(req) + if err != nil { + return nil, err + } + email, phone := setContactToContact(req.GetUser().GetContact()) + return &command.ChangeSchemaUser{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + SchemaUser: schemaUser, + Email: email, + Phone: phone, + }, nil +} + +func setSchemaUserToSchemaUser(req *user.PatchUserRequest) (_ *command.SchemaUser, err error) { + if req.GetUser() == nil { + return nil, nil + } var data []byte if req.GetUser().Data != nil { data, err = req.GetUser().GetData().MarshalJSON() @@ -110,45 +128,19 @@ func patchUserRequestToChangeSchemaUser(req *user.PatchUserRequest) (_ *command. } } - var email *command.Email - var phone *command.Phone - if req.GetUser().GetContact() != nil { - if req.GetUser().GetContact().GetEmail() != nil { - email = &command.Email{ - Address: domain.EmailAddress(req.GetUser().GetContact().Email.Address), - } - if req.GetUser().GetContact().Email.GetIsVerified() { - email.Verified = true - } - if req.GetUser().GetContact().Email.GetReturnCode() != nil { - email.ReturnCode = true - } - if req.GetUser().GetContact().Email.GetSendCode() != nil { - email.URLTemplate = req.GetUser().GetContact().Email.GetSendCode().GetUrlTemplate() - } - } - if req.GetUser().GetContact().Phone != nil { - phone = &command.Phone{ - Number: domain.PhoneNumber(req.GetUser().GetContact().Phone.Number), - } - if req.GetUser().GetContact().Phone.GetIsVerified() { - phone.Verified = true - } - if req.GetUser().GetContact().Phone.GetReturnCode() != nil { - phone.ReturnCode = true - } - } - } - return &command.ChangeSchemaUser{ - ResourceOwner: organizationToUpdateResourceOwner(req.Organization), - ID: req.GetId(), - SchemaID: req.GetUser().SchemaId, - Data: data, - Email: email, - Phone: phone, + return &command.SchemaUser{ + SchemaID: req.GetUser().GetSchemaId(), + Data: data, }, nil } +func setContactToContact(contact *user.SetContact) (*command.Email, *command.Phone) { + if contact == nil { + return nil, nil + } + return setEmailToEmail(contact.GetEmail()), setPhoneToPhone(contact.GetPhone()) +} + func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { if err := checkUserSchemaEnabled(ctx); err != nil { return nil, err diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go index f779a98d87..c562f9613a 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + schema "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha" ) var ( @@ -51,7 +52,7 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) - require.NoError(ttt, err) + assert.NoError(ttt, err) if f.UserSchema.GetEnabled() { return } @@ -59,4 +60,13 @@ func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { retryDuration, time.Second, "timed out waiting for ensuring instance feature") + + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + _, err := instance.Client.UserSchemaV3.SearchUserSchemas(ctx, &schema.SearchUserSchemasRequest{}) + assert.NoError(ttt, err) + }, + retryDuration, + time.Second, + "timed out waiting for ensuring instance feature call") } diff --git a/internal/command/command.go b/internal/command/command.go index 89f23e6ff7..30e383c5df 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -189,6 +189,9 @@ type AppendReducer interface { } func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer, cmds ...eventstore.Command) error { + if len(cmds) == 0 { + return nil + } events, err := c.eventstore.Push(ctx, cmds...) if err != nil { return err @@ -196,6 +199,20 @@ func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer return AppendAndReduce(object, events...) } +type AppendReducerDetails interface { + AppendEvents(...eventstore.Event) + // TODO: Why is it allowed to return an error here? + Reduce() error + GetWriteModel() *eventstore.WriteModel +} + +func (c *Commands) pushAppendAndReduceDetails(ctx context.Context, object AppendReducerDetails, cmds ...eventstore.Command) (*domain.ObjectDetails, error) { + if err := c.pushAppendAndReduce(ctx, object, cmds...); err != nil { + return nil, err + } + return writeModelToObjectDetails(object.GetWriteModel()), nil +} + func AppendAndReduce(object AppendReducer, events ...eventstore.Event) error { object.AppendEvents(events...) return object.Reduce() diff --git a/internal/command/user_v3.go b/internal/command/user_v3.go index d5a097ce27..2251baa136 100644 --- a/internal/command/user_v3.go +++ b/internal/command/user_v3.go @@ -6,17 +6,13 @@ import ( "encoding/json" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" domain_schema "github.com/zitadel/zitadel/internal/domain/schema" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user/schemauser" "github.com/zitadel/zitadel/internal/zerrors" ) type CreateSchemaUser struct { - Details *domain.ObjectDetails - SchemaID string schemaRevision uint64 @@ -25,9 +21,9 @@ type CreateSchemaUser struct { Data json.RawMessage Email *Email - ReturnCodeEmail string + ReturnCodeEmail *string Phone *Phone - ReturnCodePhone string + ReturnCodePhone *string } func (s *CreateSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { @@ -99,44 +95,36 @@ func (c *Commands) getSchemaRoleForWrite(ctx context.Context, resourceOwner, use return domain_schema.RoleOwner, nil } -func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser, alg crypto.EncryptionAlgorithm) (err error) { +func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser) (*domain.ObjectDetails, error) { if err := user.Valid(ctx, c); err != nil { - return err + return nil, err } writeModel, err := c.getSchemaUserExists(ctx, user.ResourceOwner, user.ID) if err != nil { - return err - } - if writeModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists") + return nil, err } - userAgg := UserV3AggregateFromWriteModel(&writeModel.WriteModel) - events := []eventstore.Command{ - schemauser.NewCreatedEvent(ctx, - userAgg, - user.SchemaID, user.schemaRevision, user.Data, - ), + events, codeEmail, codePhone, err := writeModel.NewCreated(ctx, + user.SchemaID, + user.schemaRevision, + user.Data, + user.Email, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err } - if user.Email != nil { - events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, writeModel, events, userAgg, user.Email, alg) - if err != nil { - return err - } + if codeEmail != "" { + user.ReturnCodeEmail = &codeEmail } - if user.Phone != nil { - events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, writeModel, events, userAgg, user.Phone, alg) - if err != nil { - return err - } + if codePhone != "" { + user.ReturnCodePhone = &codePhone } - - if err := c.pushAppendAndReduce(ctx, writeModel, events...); err != nil { - return err - } - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } func (c *Commands) DeleteSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { @@ -147,50 +135,38 @@ func (c *Commands) DeleteSchemaUser(ctx context.Context, resourceOwner, id strin if err != nil { return nil, err } - if !writeModel.Exists() { - return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound") - } - if err := c.checkPermissionDeleteUser(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + + events, err := writeModel.NewDelete(ctx) + if err != nil { return nil, err } - if err := c.pushAppendAndReduce(ctx, writeModel, - schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), - ); err != nil { - return nil, err - } - return writeModelToObjectDetails(&writeModel.WriteModel), nil + + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } type ChangeSchemaUser struct { - Details *domain.ObjectDetails - - SchemaID *string schemaWriteModel *UserSchemaWriteModel ResourceOwner string ID string - Data json.RawMessage + + SchemaUser *SchemaUser Email *Email - ReturnCodeEmail string + ReturnCodeEmail *string Phone *Phone - ReturnCodePhone string + ReturnCodePhone *string } -func (s *ChangeSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { +type SchemaUser struct { + SchemaID string + Data json.RawMessage +} + +func (s *ChangeSchemaUser) Valid() (err error) { if s.ID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-gEJR1QOGHb", "Errors.IDMissing") } - if s.SchemaID != nil { - s.schemaWriteModel, err = c.getSchemaWriteModelByID(ctx, "", *s.SchemaID) - if err != nil { - return err - } - if !s.schemaWriteModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists") - } - } - if s.Email != nil && s.Email.Address != "" { if err := s.Email.Validate(); err != nil { return err @@ -206,92 +182,56 @@ func (s *ChangeSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { return nil } -func (s *ChangeSchemaUser) ValidData(ctx context.Context, c *Commands, existingUser *UserV3WriteModel) (err error) { - // get role for permission check in schema through extension - role, err := c.getSchemaRoleForWrite(ctx, existingUser.ResourceOwner, existingUser.AggregateID) - if err != nil { - return err - } - - if s.schemaWriteModel == nil { - s.schemaWriteModel, err = c.getSchemaWriteModelByID(ctx, "", existingUser.SchemaID) - if err != nil { - return err - } - } - - schema, err := domain_schema.NewSchema(role, bytes.NewReader(s.schemaWriteModel.Schema)) - if err != nil { - return err - } - - // if data not changed but a new schema or revision should be used - data := s.Data - if s.Data == nil { - data = existingUser.Data - } - - var v interface{} - if err := json.Unmarshal(data, &v); err != nil { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid") - } - - if err := schema.Validate(v); err != nil { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid") - } - return nil -} - -func (c *Commands) ChangeSchemaUser(ctx context.Context, user *ChangeSchemaUser, alg crypto.EncryptionAlgorithm) (err error) { - if err := user.Valid(ctx, c); err != nil { - return err +func (c *Commands) ChangeSchemaUser(ctx context.Context, user *ChangeSchemaUser) (*domain.ObjectDetails, error) { + if err := user.Valid(); err != nil { + return nil, err } writeModel, err := c.getSchemaUserWriteModelByID(ctx, user.ResourceOwner, user.ID) if err != nil { - return err + return nil, err } if !writeModel.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") } - userAgg := UserV3AggregateFromWriteModel(&writeModel.WriteModel) - events := make([]eventstore.Command, 0) - if user.Data != nil || user.SchemaID != nil { - if err := user.ValidData(ctx, c, writeModel); err != nil { - return err - } - updateEvent := writeModel.NewUpdatedEvent(ctx, - userAgg, - user.schemaWriteModel.AggregateID, - user.schemaWriteModel.SchemaRevision, - user.Data, - ) - if updateEvent != nil { - events = append(events, updateEvent) - } + schemaID := writeModel.SchemaID + if user.SchemaUser != nil && user.SchemaUser.SchemaID != "" { + schemaID = user.SchemaUser.SchemaID } - if user.Email != nil { - events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, writeModel, events, userAgg, user.Email, alg) + + var schemaWM *UserSchemaWriteModel + if user.SchemaUser != nil { + schemaWriteModel, err := c.getSchemaWriteModelByID(ctx, "", schemaID) if err != nil { - return err + return nil, err } - } - if user.Phone != nil { - events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, writeModel, events, userAgg, user.Phone, alg) - if err != nil { - return err + if !schemaWriteModel.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists") } + schemaWM = schemaWriteModel } - if len(events) == 0 { - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + + events, codeEmail, codePhone, err := writeModel.NewUpdate(ctx, + schemaWM, + user.SchemaUser, + user.Email, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err } - if err := c.pushAppendAndReduce(ctx, writeModel, events...); err != nil { - return err + + if codeEmail != "" { + user.ReturnCodeEmail = &codeEmail } - user.Details = writeModelToObjectDetails(&writeModel.WriteModel) - return nil + if codePhone != "" { + user.ReturnCodePhone = &codePhone + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) } func (c *Commands) checkPermissionUpdateUserState(ctx context.Context, resourceOwner, userID string) error { @@ -368,7 +308,7 @@ func (c *Commands) ActivateSchemaUser(ctx context.Context, resourceOwner, id str if id == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-17XupGvxBJ", "Errors.IDMissing") } - writeModel, err := c.getSchemaUserExists(ctx, "", id) + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) if err != nil { return nil, err } @@ -386,65 +326,8 @@ func (c *Commands) ActivateSchemaUser(ctx context.Context, resourceOwner, id str return writeModelToObjectDetails(&writeModel.WriteModel), nil } -func (c *Commands) updateSchemaUserEmail(ctx context.Context, existing *UserV3WriteModel, events []eventstore.Command, agg *eventstore.Aggregate, email *Email, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - if existing.Email == string(email.Address) { - return events, plainCode, nil - } - - events = append(events, schemauser.NewEmailUpdatedEvent(ctx, - agg, - email.Address, - )) - if email.Verified { - events = append(events, schemauser.NewEmailVerifiedEvent(ctx, agg)) - } else { - cryptoCode, err := c.newEmailCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck - if err != nil { - return nil, "", err - } - if email.ReturnCode { - plainCode = cryptoCode.Plain - } - events = append(events, schemauser.NewEmailCodeAddedEvent(ctx, agg, - cryptoCode.Crypted, - cryptoCode.Expiry, - email.URLTemplate, - email.ReturnCode, - )) - } - return events, plainCode, nil -} - -func (c *Commands) updateSchemaUserPhone(ctx context.Context, existing *UserV3WriteModel, events []eventstore.Command, agg *eventstore.Aggregate, phone *Phone, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - if existing.Phone == string(phone.Number) { - return events, plainCode, nil - } - - events = append(events, schemauser.NewPhoneUpdatedEvent(ctx, - agg, - phone.Number, - )) - if phone.Verified { - events = append(events, schemauser.NewPhoneVerifiedEvent(ctx, agg)) - } else { - cryptoCode, err := c.newPhoneCode(ctx, c.eventstore.Filter, alg) //nolint:staticcheck - if err != nil { - return nil, "", err - } - if phone.ReturnCode { - plainCode = cryptoCode.Plain - } - events = append(events, schemauser.NewPhoneCodeAddedEvent(ctx, agg, - cryptoCode.Crypted, - cryptoCode.Expiry, - phone.ReturnCode, - )) - } - return events, plainCode, nil -} - func (c *Commands) getSchemaUserExists(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { - writeModel := NewExistsUserV3WriteModel(resourceOwner, id) + writeModel := NewExistsUserV3WriteModel(resourceOwner, id, c.checkPermission) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } @@ -452,7 +335,23 @@ func (c *Commands) getSchemaUserExists(ctx context.Context, resourceOwner, id st } func (c *Commands) getSchemaUserWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { - writeModel := NewUserV3WriteModel(resourceOwner, id) + writeModel := NewUserV3WriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserEmailWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3EmailWriteModel(resourceOwner, id, c.checkPermission) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) getSchemaUserPhoneWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3PhoneWriteModel(resourceOwner, id, c.checkPermission) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } diff --git a/internal/command/user_v3_email.go b/internal/command/user_v3_email.go new file mode 100644 index 0000000000..9fa3a235f5 --- /dev/null +++ b/internal/command/user_v3_email.go @@ -0,0 +1,115 @@ +package command + +import ( + "context" + "io" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeSchemaUserEmail struct { + ResourceOwner string + ID string + + Email *Email + ReturnCode *string +} + +func (s *ChangeSchemaUserEmail) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-0oj2PquNGA", "Errors.IDMissing") + } + if s.Email != nil && s.Email.Address != "" { + if err := s.Email.Validate(); err != nil { + return err + } + } + if s.Email != nil && s.Email.URLTemplate != "" { + if err := domain.RenderConfirmURLTemplate(io.Discard, s.Email.URLTemplate, s.ID, "code", "orgID"); err != nil { + return err + } + } + return nil +} + +func (c *Commands) ChangeSchemaUserEmail(ctx context.Context, user *ChangeSchemaUserEmail) (_ *domain.ObjectDetails, err error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewEmailUpdate(ctx, + user.Email, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.ReturnCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) VerifySchemaUserEmail(ctx context.Context, resourceOwner, id, code string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-y3n4Sdu8j5", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewEmailVerify(ctx, + func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error { + return crypto.VerifyCode(creationDate, expiry, cryptoCode, code, c.userEncryption) + }, + ) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ResendSchemaUserEmailCode struct { + ResourceOwner string + ID string + + URLTemplate string + ReturnCode bool + PlainCode *string +} + +func (c *Commands) ResendSchemaUserEmailCode(ctx context.Context, user *ResendSchemaUserEmailCode) (*domain.ObjectDetails, error) { + if user.ID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-KvPc5o9GeJ", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserEmailWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewResendEmailCode(ctx, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newEmailCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + user.URLTemplate, + user.ReturnCode, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.PlainCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} diff --git a/internal/command/user_v3_email_test.go b/internal/command/user_v3_email_test.go new file mode 100644 index 0000000000..5516b32f19 --- /dev/null +++ b/internal/command/user_v3_email_test.go @@ -0,0 +1,1076 @@ +package command + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ChangeSchemaUserEmail(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUserEmail + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-0oj2PquNGA", "Errors.IDMissing")) + }, + }, + }, + { + "no valid email, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "no valid template, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail", URLTemplate: "{{"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "email update, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound")) + }, + }, + }, + { + "email update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "noemail@example.com"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email update, email not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "test@example.com", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email update, email return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "emailverify", + }, + }, + { + "user updated, email to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserEmail{ + ID: "user1", + Email: &Email{Address: "test@example.com", Verified: true}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ChangeSchemaUserEmail(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.ReturnCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.ReturnCode) + } + } + }) + } +} + +func TestCommands_VerifySchemaUserEmail(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + id string + code string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-y3n4Sdu8j5", "Errors.IDMissing")) + }, + }, + }, + { + "email verify, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound")) + }, + }, + }, + { + "email verify, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email verify, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email verify, wrong code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + "email verify, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + code: "emailverify", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.VerifySchemaUserEmail(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.code) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ResendSchemaUserEmailCode(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ResendSchemaUserEmailCode + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-KvPc5o9GeJ", "Errors.IDMissing")) + }, + }, + }, + { + "email code resend, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound")) + }, + }, + }, + { + "email code resend, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")) + }, + }, + }, + { + "email code resend, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + eventFromEventPusher( + schemauser.NewEmailVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty")) + }, + }, + }, + { + "email code resend, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "email code resend, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify2"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "email code resend, return, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewEmailCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify2"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserEmailCode{ + ID: "user1", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + ReturnCode: true, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "emailverify2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ResendSchemaUserEmailCode(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.PlainCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.PlainCode) + } + } + }) + } +} diff --git a/internal/command/user_v3_model.go b/internal/command/user_v3_model.go index 6231558178..574ed6cd63 100644 --- a/internal/command/user_v3_model.go +++ b/internal/command/user_v3_model.go @@ -4,10 +4,15 @@ import ( "bytes" "context" "encoding/json" + "time" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" + domain_schema "github.com/zitadel/zitadel/internal/domain/schema" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" ) type UserV3WriteModel struct { @@ -23,37 +28,77 @@ type UserV3WriteModel struct { Email string IsEmailVerified bool EmailVerifiedFailedCount int + EmailCode *VerifyCode + Phone string IsPhoneVerified bool PhoneVerifiedFailedCount int + PhoneCode *VerifyCode Data json.RawMessage Locked bool State domain.UserState + + checkPermission domain.PermissionCheck + writePermissionCheck bool } -func NewExistsUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { +func (wm *UserV3WriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + +type VerifyCode struct { + Code *crypto.CryptoValue + CreationDate time.Time + Expiry time.Duration +} + +func NewExistsUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { return &UserV3WriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, }, - PhoneWM: false, - EmailWM: false, - DataWM: false, + PhoneWM: false, + EmailWM: false, + DataWM: false, + checkPermission: checkPermission, } } -func NewUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { +func NewUserV3WriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { return &UserV3WriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, }, - PhoneWM: true, - EmailWM: true, - DataWM: true, + PhoneWM: true, + EmailWM: true, + DataWM: true, + checkPermission: checkPermission, + } +} + +func NewUserV3EmailWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { + return &UserV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + EmailWM: true, + checkPermission: checkPermission, + } +} + +func NewUserV3PhoneWriteModel(resourceOwner, userID string, checkPermission domain.PermissionCheck) *UserV3WriteModel { + return &UserV3WriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + PhoneWM: true, + checkPermission: checkPermission, } } @@ -83,24 +128,38 @@ func (wm *UserV3WriteModel) Reduce() error { wm.Email = string(e.EmailAddress) wm.IsEmailVerified = false wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.EmailCodeAddedEvent: wm.IsEmailVerified = false wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = &VerifyCode{ + Code: e.Code, + CreationDate: e.CreationDate(), + Expiry: e.Expiry, + } case *schemauser.EmailVerifiedEvent: wm.IsEmailVerified = true wm.EmailVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.EmailVerificationFailedEvent: wm.EmailVerifiedFailedCount += 1 case *schemauser.PhoneUpdatedEvent: wm.Phone = string(e.PhoneNumber) wm.IsPhoneVerified = false wm.PhoneVerifiedFailedCount = 0 + wm.EmailCode = nil case *schemauser.PhoneCodeAddedEvent: wm.IsPhoneVerified = false wm.PhoneVerifiedFailedCount = 0 + wm.PhoneCode = &VerifyCode{ + Code: e.Code, + CreationDate: e.CreationDate(), + Expiry: e.Expiry, + } case *schemauser.PhoneVerifiedEvent: wm.PhoneVerifiedFailedCount = 0 wm.IsPhoneVerified = true + wm.PhoneCode = nil case *schemauser.PhoneVerificationFailedEvent: wm.PhoneVerifiedFailedCount += 1 case *schemauser.LockedEvent: @@ -156,13 +215,159 @@ func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder { EventTypes(eventtypes...).Builder() } -func (wm *UserV3WriteModel) NewUpdatedEvent( +func (wm *UserV3WriteModel) NewCreated( ctx context.Context, - agg *eventstore.Aggregate, schemaID string, schemaRevision uint64, data json.RawMessage, -) *schemauser.UpdatedEvent { + email *Email, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, codeEmail string, codePhone string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", "", err + } + if wm.Exists() { + return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.AlreadyExists") + } + events := []eventstore.Command{ + schemauser.NewCreatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + schemaID, schemaRevision, data, + ), + } + if email != nil { + emailEvents, plainCodeEmail, err := wm.NewEmailCreate(ctx, + email, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodeEmail != "" { + codeEmail = plainCodeEmail + } + events = append(events, emailEvents...) + } + + if phone != nil { + phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx, + phone, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodePhone != "" { + codePhone = plainCodePhone + } + events = append(events, phoneEvents...) + } + + return events, codeEmail, codePhone, nil +} + +func (wm *UserV3WriteModel) getSchemaRoleForWrite(ctx context.Context, resourceOwner, userID string) (domain_schema.Role, error) { + if userID == authz.GetCtxData(ctx).UserID { + return domain_schema.RoleSelf, nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return domain_schema.RoleUnspecified, err + } + return domain_schema.RoleOwner, nil +} + +func (wm *UserV3WriteModel) validateData(ctx context.Context, data []byte, schemaWM *UserSchemaWriteModel) (string, uint64, error) { + // get role for permission check in schema through extension + role, err := wm.getSchemaRoleForWrite(ctx, wm.ResourceOwner, wm.AggregateID) + if err != nil { + return "", 0, err + } + + schema, err := domain_schema.NewSchema(role, bytes.NewReader(schemaWM.Schema)) + if err != nil { + return "", 0, err + } + + // if data not changed but a new schema or revision should be used + if data == nil { + data = wm.Data + } + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return "", 0, zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid") + } + + if err := schema.Validate(v); err != nil { + return "", 0, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid") + } + return schemaWM.AggregateID, schemaWM.SchemaRevision, nil +} + +func (wm *UserV3WriteModel) NewUpdate( + ctx context.Context, + schemaWM *UserSchemaWriteModel, + user *SchemaUser, + email *Email, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, codeEmail string, codePhone string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", "", err + } + if !wm.Exists() { + return nil, "", "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") + } + events := make([]eventstore.Command, 0) + if user != nil { + schemaID, schemaRevision, err := wm.validateData(ctx, user.Data, schemaWM) + if err != nil { + return nil, "", "", err + } + userEvents := wm.newUpdatedEvents(ctx, + schemaID, + schemaRevision, + user.Data, + ) + events = append(events, userEvents...) + } + if email != nil { + emailEvents, plainCodeEmail, err := wm.NewEmailUpdate(ctx, + email, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodeEmail != "" { + codeEmail = plainCodeEmail + } + events = append(events, emailEvents...) + } + + if phone != nil { + phoneEvents, plainCodePhone, err := wm.NewPhoneCreate(ctx, + phone, + code, + ) + if err != nil { + return nil, "", "", err + } + if plainCodePhone != "" { + codePhone = plainCodePhone + } + events = append(events, phoneEvents...) + } + + return events, codeEmail, codePhone, nil +} + +func (wm *UserV3WriteModel) newUpdatedEvents( + ctx context.Context, + schemaID string, + schemaRevision uint64, + data json.RawMessage, +) []eventstore.Command { changes := make([]schemauser.Changes, 0) if wm.SchemaID != schemaID { changes = append(changes, schemauser.ChangeSchemaID(schemaID)) @@ -176,7 +381,20 @@ func (wm *UserV3WriteModel) NewUpdatedEvent( if len(changes) == 0 { return nil } - return schemauser.NewUpdatedEvent(ctx, agg, changes) + return []eventstore.Command{schemauser.NewUpdatedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel), changes)} +} + +func (wm *UserV3WriteModel) NewDelete( + ctx context.Context, +) (_ []eventstore.Command, err error) { + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-syHyCsGmvM", "Errors.User.NotFound") + } + if err := wm.checkPermissionDelete(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + return []eventstore.Command{schemauser.NewDeletedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel))}, nil + } func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -192,3 +410,271 @@ func UserV3AggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggreg func (wm *UserV3WriteModel) Exists() bool { return wm.State != domain.UserStateDeleted && wm.State != domain.UserStateUnspecified } + +func (wm *UserV3WriteModel) checkPermissionWrite( + ctx context.Context, + resourceOwner string, + userID string, +) error { + if wm.writePermissionCheck { + return nil + } + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + if err := wm.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return err + } + wm.writePermissionCheck = true + return nil +} + +func (wm *UserV3WriteModel) checkPermissionDelete( + ctx context.Context, + resourceOwner string, + userID string, +) error { + if userID != "" && userID == authz.GetCtxData(ctx).UserID { + return nil + } + return wm.checkPermission(ctx, domain.PermissionUserDelete, resourceOwner, userID) +} + +func (wm *UserV3WriteModel) NewEmailCreate( + ctx context.Context, + email *Email, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if email == nil || wm.Email == string(email.Address) { + return nil, "", nil + } + events := []eventstore.Command{ + schemauser.NewEmailUpdatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + email.Address, + ), + } + if email.Verified { + events = append(events, wm.newEmailVerifiedEvent(ctx)) + } else { + codeEvent, code, err := wm.newEmailCodeAddedEvent(ctx, code, email.URLTemplate, email.ReturnCode) + if err != nil { + return nil, "", err + } + events = append(events, codeEvent) + if code != "" { + plainCode = code + } + } + return events, plainCode, nil +} + +func (wm *UserV3WriteModel) NewEmailUpdate( + ctx context.Context, + email *Email, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.EmailWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-nJ0TQFuRmP", "Errors.User.NotFound") + } + return wm.NewEmailCreate(ctx, email, code) +} + +func (wm *UserV3WriteModel) NewEmailVerify( + ctx context.Context, + verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error, +) ([]eventstore.Command, error) { + if !wm.EmailWM { + return nil, nil + } + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-qbGyMPvjvj", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + if wm.EmailCode == nil { + return nil, nil + } + if err := verify(wm.EmailCode.CreationDate, wm.EmailCode.Expiry, wm.EmailCode.Code); err != nil { + return nil, err + } + return []eventstore.Command{wm.newEmailVerifiedEvent(ctx)}, nil +} + +func (wm *UserV3WriteModel) newEmailVerifiedEvent( + ctx context.Context, +) *schemauser.EmailVerifiedEvent { + return schemauser.NewEmailVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel)) +} + +func (wm *UserV3WriteModel) NewResendEmailCode( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + urlTemplate string, + isReturnCode bool, +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.EmailWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-EajeF6ypOV", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if wm.EmailCode == nil { + return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-QRkNTBwF8q", "Errors.User.Code.Empty") + } + event, plainCode, err := wm.newEmailCodeAddedEvent(ctx, code, urlTemplate, isReturnCode) + if err != nil { + return nil, "", err + } + return []eventstore.Command{event}, plainCode, nil +} + +func (wm *UserV3WriteModel) newEmailCodeAddedEvent( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + urlTemplate string, + isReturnCode bool, +) (_ *schemauser.EmailCodeAddedEvent, plainCode string, err error) { + cryptoCode, err := code(ctx) + if err != nil { + return nil, "", err + } + if isReturnCode { + plainCode = cryptoCode.Plain + } + return schemauser.NewEmailCodeAddedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + cryptoCode.Crypted, + cryptoCode.Expiry, + urlTemplate, + isReturnCode, + ), plainCode, nil +} + +func (wm *UserV3WriteModel) NewPhoneCreate( + ctx context.Context, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if phone == nil || wm.Phone == string(phone.Number) { + return nil, "", nil + } + events := []eventstore.Command{ + schemauser.NewPhoneUpdatedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + phone.Number, + ), + } + if phone.Verified { + events = append(events, wm.newPhoneVerifiedEvent(ctx)) + } else { + codeEvent, code, err := wm.newPhoneCodeAddedEvent(ctx, code, phone.ReturnCode) + if err != nil { + return nil, "", err + } + events = append(events, codeEvent) + if code != "" { + plainCode = code + } + } + return events, plainCode, nil +} + +func (wm *UserV3WriteModel) NewPhoneUpdate( + ctx context.Context, + phone *Phone, + code func(context.Context) (*EncryptedCode, error), +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.PhoneWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound") + } + return wm.NewPhoneCreate(ctx, phone, code) +} + +func (wm *UserV3WriteModel) NewPhoneVerify( + ctx context.Context, + verify func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error, +) ([]eventstore.Command, error) { + if !wm.PhoneWM { + return nil, nil + } + if !wm.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + if wm.PhoneCode == nil { + return nil, nil + } + if err := verify(wm.PhoneCode.CreationDate, wm.PhoneCode.Expiry, wm.PhoneCode.Code); err != nil { + return nil, err + } + return []eventstore.Command{wm.newPhoneVerifiedEvent(ctx)}, nil +} + +func (wm *UserV3WriteModel) newPhoneVerifiedEvent( + ctx context.Context, +) *schemauser.PhoneVerifiedEvent { + return schemauser.NewPhoneVerifiedEvent(ctx, UserV3AggregateFromWriteModel(&wm.WriteModel)) +} + +func (wm *UserV3WriteModel) NewResendPhoneCode( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + isReturnCode bool, +) (_ []eventstore.Command, plainCode string, err error) { + if !wm.PhoneWM { + return nil, "", nil + } + if !wm.Exists() { + return nil, "", zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound") + } + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, "", err + } + if wm.PhoneCode == nil { + return nil, "", zerrors.ThrowPreconditionFailed(err, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty") + } + event, plainCode, err := wm.newPhoneCodeAddedEvent(ctx, code, isReturnCode) + if err != nil { + return nil, "", err + } + return []eventstore.Command{event}, plainCode, nil +} + +func (wm *UserV3WriteModel) newPhoneCodeAddedEvent( + ctx context.Context, + code func(context.Context) (*EncryptedCode, error), + isReturnCode bool, +) (_ *schemauser.PhoneCodeAddedEvent, plainCode string, err error) { + cryptoCode, err := code(ctx) + if err != nil { + return nil, "", err + } + if isReturnCode { + plainCode = cryptoCode.Plain + } + return schemauser.NewPhoneCodeAddedEvent(ctx, + UserV3AggregateFromWriteModel(&wm.WriteModel), + cryptoCode.Crypted, + cryptoCode.Expiry, + isReturnCode, + ), plainCode, nil +} diff --git a/internal/command/user_v3_phone.go b/internal/command/user_v3_phone.go new file mode 100644 index 0000000000..65ca36a0ee --- /dev/null +++ b/internal/command/user_v3_phone.go @@ -0,0 +1,107 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeSchemaUserPhone struct { + ResourceOwner string + ID string + + Phone *Phone + ReturnCode *string +} + +func (s *ChangeSchemaUserPhone) Valid() (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-DkQ9aurv5u", "Errors.IDMissing") + } + if s.Phone != nil && s.Phone.Number != "" { + if s.Phone.Number, err = s.Phone.Number.Normalize(); err != nil { + return err + } + } + return nil +} + +func (c *Commands) ChangeSchemaUserPhone(ctx context.Context, user *ChangeSchemaUserPhone) (_ *domain.ObjectDetails, err error) { + if err := user.Valid(); err != nil { + return nil, err + } + + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewPhoneUpdate(ctx, + user.Phone, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.ReturnCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +func (c *Commands) VerifySchemaUserPhone(ctx context.Context, resourceOwner, id, code string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-R4LKY44Ke3", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + + events, err := writeModel.NewPhoneVerify(ctx, + func(creationDate time.Time, expiry time.Duration, cryptoCode *crypto.CryptoValue) error { + return crypto.VerifyCode(creationDate, expiry, cryptoCode, code, c.userEncryption) + }, + ) + if err != nil { + return nil, err + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} + +type ResendSchemaUserPhoneCode struct { + ResourceOwner string + ID string + + ReturnCode bool + PlainCode *string +} + +func (c *Commands) ResendSchemaUserPhoneCode(ctx context.Context, user *ResendSchemaUserPhoneCode) (*domain.ObjectDetails, error) { + if user.ID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-zmxIFR2nMo", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserPhoneWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return nil, err + } + + events, plainCode, err := writeModel.NewResendPhoneCode(ctx, + func(ctx context.Context) (*EncryptedCode, error) { + return c.newPhoneCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint:staticcheck + }, + user.ReturnCode, + ) + if err != nil { + return nil, err + } + if plainCode != "" { + user.PlainCode = &plainCode + } + return c.pushAppendAndReduceDetails(ctx, writeModel, events...) +} diff --git a/internal/command/user_v3_phone_test.go b/internal/command/user_v3_phone_test.go new file mode 100644 index 0000000000..8a5a1ae0b0 --- /dev/null +++ b/internal/command/user_v3_phone_test.go @@ -0,0 +1,1040 @@ +package command + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user/schema" + "github.com/zitadel/zitadel/internal/repository/user/schemauser" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_ChangeSchemaUserPhone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUserPhone + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-DkQ9aurv5u", "Errors.IDMissing")) + }, + }, + }, + { + "no valid phone, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{Number: "nonumber"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "PHONE-so0wa", "Errors.User.Phone.Invalid")) + }, + }, + }, { + "phone update, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-b33QAVgel6", "Errors.User.NotFound")) + }, + }, + }, + { + "phone update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{Number: "+41791234567"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone update, phone not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "+41791234567", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone update, phone return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "phoneverify", + }, + }, + { + "user updated, phone to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, time.Hour*1, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUserPhone{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + Verified: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ChangeSchemaUserPhone(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.ReturnCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.ReturnCode) + } + }) + } +} + +func TestCommands_VerifySchemaUserPhone(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner string + id string + code string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-R4LKY44Ke3", "Errors.IDMissing")) + }, + }, + }, + { + "phone verify, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-bx2OLtgGNS", "Errors.User.NotFound")) + }, + }, + }, + { + "phone verify, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone verify, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone verify, wrong code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid")) + }, + }, + }, + { + "phone verify, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + id: "user1", + code: "phoneverify", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.VerifySchemaUserPhone(tt.args.ctx, tt.args.resourceOwner, tt.args.id, tt.args.code) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ResendSchemaUserPhoneCode(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ResendSchemaUserPhoneCode + } + type res struct { + returnCode string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-zmxIFR2nMo", "Errors.IDMissing")) + }, + }, + }, + { + "phone code resend, user not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-z8Bu9vuL9s", "Errors.User.NotFound")) + }, + }, + }, + { + "phone code resend, no code", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")) + }, + }, + }, + { + "phone code resend, already verified", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + eventFromEventPusher( + schemauser.NewPhoneVerifiedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-fEsHdqECzb", "Errors.User.Code.Empty")) + }, + }, + }, + { + "phone code resend, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "phone code resend, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify2"), + }, + time.Hour*1, + false, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "phone code resend, return, ok", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + eventFromEventPusherWithCreationDateNow( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + expectPush( + eventFromEventPusher( + schemauser.NewPhoneCodeAddedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify2"), + }, + time.Hour*1, + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify2", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ResendSchemaUserPhoneCode{ + ID: "user1", + ReturnCode: true, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCode: "phoneverify2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + } + details, err := c.ResendSchemaUserPhoneCode(tt.args.ctx, tt.args.user) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.details, details) + if tt.res.returnCode != "" { + assert.NotNil(t, tt.args.user.PlainCode) + assert.Equal(t, tt.res.returnCode, *tt.args.user.PlainCode) + } + } + }) + } +} diff --git a/internal/command/user_v3_test.go b/internal/command/user_v3_test.go index 69794b3c2e..4825626b15 100644 --- a/internal/command/user_v3_test.go +++ b/internal/command/user_v3_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -875,8 +874,9 @@ func TestCommands_CreateSchemaUser(t *testing.T) { idGenerator: tt.fields.idGenerator, checkPermission: tt.fields.checkPermission, newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), } - err := c.CreateSchemaUser(tt.args.ctx, tt.args.user, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + details, err := c.CreateSchemaUser(tt.args.ctx, tt.args.user) if tt.res.err == nil { assert.NoError(t, err) } @@ -884,14 +884,16 @@ func TestCommands_CreateSchemaUser(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.details, tt.args.user.Details) + assertObjectDetails(t, tt.res.details, details) } if tt.res.returnCodePhone != "" { - assert.Equal(t, tt.res.returnCodePhone, tt.args.user.ReturnCodePhone) + assert.NotNil(t, tt.args.user.ReturnCodePhone) + assert.Equal(t, tt.res.returnCodePhone, *tt.args.user.ReturnCodePhone) } if tt.res.returnCodeEmail != "" { - assert.Equal(t, tt.res.returnCodeEmail, tt.args.user.ReturnCodeEmail) + assert.NotNil(t, tt.args.user.ReturnCodeEmail) + assert.Equal(t, tt.res.returnCodeEmail, *tt.args.user.ReturnCodeEmail) } }) } @@ -1988,6 +1990,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "schema not existing, error", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -1995,8 +2010,10 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + }, }, }, res{ @@ -2060,6 +2077,25 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), ), checkPermission: newMockPermissionCheckNotAllowed(), }, @@ -2067,9 +2103,11 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ ID: "user1", - Data: json.RawMessage(`{ + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ "name": "user" }`), + }, }, }, res{ @@ -2134,9 +2172,11 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ ID: "user1", - Data: json.RawMessage(`{ + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ "name": "user2" }`), + }, }, }, res{ @@ -2149,6 +2189,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user updated, changed schema", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2168,19 +2221,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2196,8 +2236,10 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id2"), + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + }, }, }, res{ @@ -2210,6 +2252,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user updated, new schema", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2229,19 +2284,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2262,11 +2304,13 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id2"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + Data: json.RawMessage(`{ "name": "user2" }`), + }, }, }, res{ @@ -2350,9 +2394,11 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ ID: "user1", - Data: json.RawMessage(`{ + SchemaUser: &SchemaUser{ + Data: json.RawMessage(`{ "name2": "user2" }`), + }, }, }, res{ @@ -2365,6 +2411,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user updated, new schema and revision", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 2, + json.RawMessage(`{ + "name1": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2384,19 +2443,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 2, - json.RawMessage(`{ - "name1": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2418,11 +2464,13 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id2"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id2", + Data: json.RawMessage(`{ "name2": "user2" }`), + }, }, }, res{ @@ -2435,6 +2483,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, no field permission as admin", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2457,30 +2518,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), checkPermission: newMockPermissionCheckAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id1"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + Data: json.RawMessage(`{ "name": "user" }`), + }, }, }, res{ @@ -2494,6 +2544,18 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { fields{ eventstore: expectEventstore( expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( context.Background(), @@ -2515,29 +2577,18 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), }, args{ ctx: authz.NewMockContext("instanceID", "org1", "user1"), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ "name": "user" }`), + }, }, }, res{ @@ -2550,6 +2601,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, invalid data type", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2569,30 +2633,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), checkPermission: newMockPermissionCheckAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "org1", "user1"), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ "name": 1 }`), + }, }, }, res{ @@ -2605,6 +2658,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, additional property", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2624,19 +2690,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewUpdatedEvent( context.Background(), @@ -2657,12 +2710,14 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id1"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + Data: json.RawMessage(`{ "name": "user1", "additional": "property" }`), + }, }, }, res{ @@ -2675,6 +2730,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, invalid data attribute name", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2695,30 +2763,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), ), checkPermission: newMockPermissionCheckAllowed(), }, args{ ctx: authz.NewMockContext("instanceID", "org1", "user1"), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("type"), - Data: json.RawMessage(`{ + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "type", + Data: json.RawMessage(`{ "invalid": "user" }`), + }, }, }, res{ @@ -2775,6 +2832,19 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { "user update, email return", fields{ eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), expectFilter( eventFromEventPusher( schema.NewCreatedEvent( @@ -2794,19 +2864,6 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { ), ), ), - expectFilter( - eventFromEventPusher( - schemauser.NewCreatedEvent( - context.Background(), - &schemauser.NewAggregate("user1", "org1").Aggregate, - "id1", - 1, - json.RawMessage(`{ - "name": "user1" - }`), - ), - ), - ), expectPush( schemauser.NewEmailUpdatedEvent(context.Background(), &schemauser.NewAggregate("user1", "org1").Aggregate, @@ -2832,8 +2889,10 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { args{ ctx: authz.NewMockContext("instanceID", "", ""), user: &ChangeSchemaUser{ - ID: "user1", - SchemaID: gu.Ptr("id1"), + ID: "user1", + SchemaUser: &SchemaUser{ + SchemaID: "id1", + }, Email: &Email{ Address: "test@example.com", ReturnCode: true, @@ -3101,8 +3160,9 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, newEncryptedCode: tt.fields.newCode, + userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), } - err := c.ChangeSchemaUser(tt.args.ctx, tt.args.user, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + details, err := c.ChangeSchemaUser(tt.args.ctx, tt.args.user) if tt.res.err == nil { assert.NoError(t, err) } @@ -3110,14 +3170,16 @@ func TestCommands_ChangeSchemaUser(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assertObjectDetails(t, tt.res.details, tt.args.user.Details) + assertObjectDetails(t, tt.res.details, details) } if tt.res.returnCodePhone != "" { - assert.Equal(t, tt.res.returnCodePhone, tt.args.user.ReturnCodePhone) + assert.NotNil(t, tt.args.user.ReturnCodePhone) + assert.Equal(t, tt.res.returnCodePhone, *tt.args.user.ReturnCodePhone) } if tt.res.returnCodeEmail != "" { - assert.Equal(t, tt.res.returnCodeEmail, tt.args.user.ReturnCodeEmail) + assert.NotNil(t, tt.args.user.ReturnCodeEmail) + assert.Equal(t, tt.res.returnCodeEmail, *tt.args.user.ReturnCodeEmail) } }) } diff --git a/internal/integration/client.go b/internal/integration/client.go index 9bf855f5ce..dde8822acd 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -776,6 +776,32 @@ func (i *Instance) CreateSchemaUser(ctx context.Context, orgID string, schemaID return user } +func (i *Instance) UpdateSchemaUserEmail(ctx context.Context, orgID string, userID string, email string) *user_v3alpha.SetContactEmailResponse { + user, err := i.Client.UserV3Alpha.SetContactEmail(ctx, &user_v3alpha.SetContactEmailRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + Email: &user_v3alpha.SetEmail{ + Address: email, + Verification: &user_v3alpha.SetEmail_ReturnCode{}, + }, + }) + logging.OnError(err).Fatal("create user") + return user +} + +func (i *Instance) UpdateSchemaUserPhone(ctx context.Context, orgID string, userID string, phone string) *user_v3alpha.SetContactPhoneResponse { + user, err := i.Client.UserV3Alpha.SetContactPhone(ctx, &user_v3alpha.SetContactPhoneRequest{ + Organization: &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}}, + Id: userID, + Phone: &user_v3alpha.SetPhone{ + Number: phone, + Verification: &user_v3alpha.SetPhone_ReturnCode{}, + }, + }) + logging.OnError(err).Fatal("create user") + return user +} + func (i *Instance) CreateInviteCode(ctx context.Context, userID string) *user_v2.CreateInviteCodeResponse { user, err := i.Client.UserV2.CreateInviteCode(ctx, &user_v2.CreateInviteCodeRequest{ UserId: userID, diff --git a/proto/zitadel/resources/user/v3alpha/user_service.proto b/proto/zitadel/resources/user/v3alpha/user_service.proto index 91831bdc40..96e595d81d 100644 --- a/proto/zitadel/resources/user/v3alpha/user_service.proto +++ b/proto/zitadel/resources/user/v3alpha/user_service.proto @@ -1392,7 +1392,7 @@ message SetContactPhoneRequest { message SetContactPhoneResponse { zitadel.resources.object.v3alpha.Details details = 1; // The phone verification code will be set if a contact phone was set with a return_code verification option. - optional string email_code = 3 [ + optional string verification_code = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"SKJd342k\""; }