feat: add delete phone endpoint to v2 api (#8321)

# Which Problems Are Solved

- Adds delete phone endpoint to v2 api

# How the Problems Are Solved

- Adds new endpoint with DELETE method to /v2beta/users/:userId/phone
which removes currently set phone number

# Additional Changes

- Added integration test for new endpoint.

# Additional Context

- Solves
https://discord.com/channels/927474939156643850/1255557862286032996
This commit is contained in:
Joey Biscoglia 2024-07-24 09:46:07 -04:00 committed by GitHub
parent 8d13247413
commit f34897a8c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 209 additions and 0 deletions

View File

@ -40,6 +40,23 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp
}, nil
}
func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) {
details, err := s.command.RemoveUserPhone(ctx,
req.GetUserId(),
)
if err != nil {
return nil, err
}
return &user.RemovePhoneResponse{
Details: &object.Details{
Sequence: details.Sequence,
ChangeDate: timestamppb.New(details.EventDate),
ResourceOwner: details.ResourceOwner,
},
}, nil
}
func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) {
var phone *domain.Phone
switch v := req.GetVerification().(type) {

View File

@ -3,6 +3,7 @@
package user_test
import (
"context"
"fmt"
"testing"
"time"
@ -245,3 +246,99 @@ func TestServer_VerifyPhone(t *testing.T) {
})
}
}
func TestServer_RemovePhone(t *testing.T) {
userResp := Tester.CreateHumanUser(CTX)
failResp := Tester.CreateHumanUserNoPhone(CTX)
otherUser := Tester.CreateHumanUser(CTX).GetUserId()
doubleRemoveUser := Tester.CreateHumanUser(CTX)
Tester.RegisterUserPasskey(CTX, otherUser)
_, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
tests := []struct {
name string
ctx context.Context
req *user.RemovePhoneRequest
want *user.RemovePhoneResponse
wantErr bool
dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error)
}{
{
name: "remove phone",
ctx: CTX,
req: &user.RemovePhoneRequest{
UserId: userResp.GetUserId(),
},
want: &user.RemovePhoneResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
return nil, nil
},
},
{
name: "user without phone",
ctx: CTX,
req: &user.RemovePhoneRequest{
UserId: failResp.GetUserId(),
},
wantErr: true,
dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
return nil, nil
},
},
{
name: "remove previously deleted phone",
ctx: CTX,
req: &user.RemovePhoneRequest{
UserId: doubleRemoveUser.GetUserId(),
},
wantErr: true,
dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
return Client.RemovePhone(ctx, &user.RemovePhoneRequest{
UserId: doubleRemoveUser.GetUserId(),
});
},
},
{
name: "no user id",
ctx: CTX,
req: &user.RemovePhoneRequest{},
wantErr: true,
dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
return nil, nil
},
},
{
name: "other user, no permission",
ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser),
req: &user.RemovePhoneRequest{
UserId: userResp.GetUserId(),
},
wantErr: true,
dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
return nil, nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, depErr := tt.dep(tt.ctx, tt.req.UserId)
require.NoError(t, depErr)
got, err := Client.RemovePhone(tt.ctx, tt.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
integration.AssertDetails(t, tt.want, got)
})
}
}

View File

@ -140,6 +140,29 @@ func (c *Commands) verifyUserPhoneWithGenerator(ctx context.Context, userID, cod
return writeModelToObjectDetails(&cmd.model.WriteModel), nil
}
func (c *Commands) RemoveUserPhone(ctx context.Context, userID string) (*domain.ObjectDetails, error) {
return c.removeUserPhone(ctx, userID)
}
func (c *Commands) removeUserPhone(ctx context.Context, userID string) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserPhoneEvents(ctx, userID)
if err != nil {
return nil, err
}
if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
}
if err = cmd.Remove(ctx); err != nil {
return nil, err
}
if _, err = cmd.Push(ctx); err != nil {
return nil, err
}
return writeModelToObjectDetails(&cmd.model.WriteModel), nil
}
// UserPhoneEvents allows step-by-step additions of events,
// operating on the Human Phone Model.
type UserPhoneEvents struct {
@ -191,6 +214,14 @@ func (c *UserPhoneEvents) Change(ctx context.Context, phone domain.PhoneNumber)
return nil
}
func (c *UserPhoneEvents) Remove(ctx context.Context) error {
if c.model.State == domain.PhoneStateRemoved || c.model.State == domain.PhoneStateUnspecified {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-ieJ2e", "Errors.User.Phone.NotFound")
}
c.events = append(c.events, user.NewHumanPhoneRemovedEvent(ctx, c.aggregate))
return nil
}
// SetVerified sets the phone number to verified.
func (c *UserPhoneEvents) SetVerified(ctx context.Context) {
c.events = append(c.events, user.NewHumanPhoneVerifiedEvent(ctx, c.aggregate))

View File

@ -153,6 +153,30 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse
return resp
}
func (s *Tester) CreateHumanUserNoPhone(ctx context.Context) *user.AddHumanUserResponse {
resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{
Organization: &object.Organization{
Org: &object.Organization_OrgId{
OrgId: s.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
GivenName: "Mickey",
FamilyName: "Mouse",
PreferredLanguage: gu.Ptr("nl"),
Gender: gu.Ptr(user.Gender_GENDER_MALE),
},
Email: &user.SetHumanEmail{
Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
Verification: &user.SetHumanEmail_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
})
logging.OnError(err).Fatal("create human user")
return resp
}
func (s *Tester) CreateHumanUserWithTOTP(ctx context.Context, secret string) *user.AddHumanUserResponse {
resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{
Organization: &object.Organization{

View File

@ -305,6 +305,30 @@ service UserService {
};
}
rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) {
option (google.api.http) = {
delete: "/v2beta/users/{user_id}/phone"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Delete the user phone";
description: "Delete the phone number of a user."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
rpc ResendPhoneCode (ResendPhoneCodeRequest) returns (ResendPhoneCodeResponse) {
option (google.api.http) = {
post: "/v2beta/users/{user_id}/phone/resend"
@ -1124,6 +1148,22 @@ message SetPhoneResponse{
optional string verification_code = 2;
}
message RemovePhoneRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"69629026806489455\"";
}
];
}
message RemovePhoneResponse{
zitadel.object.v2beta.Details details = 1;
}
message ResendPhoneCodeRequest{
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},