feat: 0.0.1
This commit is contained in:
146 files changed
+10444
No files matched your search
@@ -0,0 +1,51 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
const (
|
||||
authHeaderKey = "authorization"
|
||||
authTypeBearer = "bearer"
|
||||
)
|
||||
|
||||
func (s *Server) authorizeUser(ctx context.Context, accessibleRoles []string) (*auth.Payload, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing metadata")
|
||||
}
|
||||
|
||||
values := md.Get(authHeaderKey)
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("missing authorization header")
|
||||
}
|
||||
|
||||
authHeader := values[0]
|
||||
fields := strings.Fields(authHeader)
|
||||
if len(fields) < 2 {
|
||||
return nil, fmt.Errorf("invalid authorization header format")
|
||||
}
|
||||
|
||||
authType := strings.ToLower(fields[0])
|
||||
if authType != authTypeBearer {
|
||||
return nil, fmt.Errorf("unsupported authorization type: %s", authType)
|
||||
}
|
||||
|
||||
accessToken := fields[1]
|
||||
payload, err := s.tokenMaker.VerifyToken(accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %v", err)
|
||||
}
|
||||
|
||||
if !role.HasPermission(payload.Role, accessibleRoles) {
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func convertUser(user db.User) *pb.User {
|
||||
return &pb.User{
|
||||
Username: user.Username,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
CreatedAt: timestamppb.New(user.CreatedAt),
|
||||
PasswordChangedAt: timestamppb.New(user.PasswordChangedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func convertLoginUserResponse(
|
||||
user db.User,
|
||||
sessionID uuid.UUID,
|
||||
accessToken string,
|
||||
refreshToken string,
|
||||
accessExpiredAt time.Time,
|
||||
refreshExpiredAt time.Time,
|
||||
) *pb.LoginUserResponse {
|
||||
return &pb.LoginUserResponse{
|
||||
User: convertUser(user),
|
||||
SessionId: sessionID.String(),
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
AccessTokenExpiresAt: timestamppb.New(accessExpiredAt),
|
||||
RefreshTokenExpiresAt: timestamppb.New(refreshExpiredAt),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func fieldViolation(field string, err error) *errdetails.BadRequest_FieldViolation {
|
||||
return &errdetails.BadRequest_FieldViolation{
|
||||
Field: field,
|
||||
Description: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func invalidArgumentError(violations []*errdetails.BadRequest_FieldViolation) error {
|
||||
badRequest := &errdetails.BadRequest{FieldViolations: violations}
|
||||
stInvalid := status.New(codes.InvalidArgument, "invalid user request")
|
||||
stDetails, err := stInvalid.WithDetails(badRequest)
|
||||
if err != nil {
|
||||
return stInvalid.Err()
|
||||
}
|
||||
return stDetails.Err()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func GrpcLogger(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
startTime := time.Now()
|
||||
result, err := handler(ctx, req)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
statusCode := codes.Unknown
|
||||
if st, ok := status.FromError(err); ok {
|
||||
statusCode = st.Code()
|
||||
}
|
||||
|
||||
logger := log.Info()
|
||||
if err != nil {
|
||||
logger = log.Error().Err(err)
|
||||
}
|
||||
|
||||
logger.Str("protocol", "grpc").
|
||||
Str("method", info.FullMethod).
|
||||
Int("status", int(statusCode)).
|
||||
Str("status_text", statusCode.String()).
|
||||
Dur("duration", duration).
|
||||
Msg("Recieved a gRPC request")
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
type ResponseRecorder struct {
|
||||
http.ResponseWriter
|
||||
StatusCode int
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (rr *ResponseRecorder) WriteHeader(statusCode int) {
|
||||
rr.StatusCode = statusCode
|
||||
rr.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (rr *ResponseRecorder) Write(b []byte) (int, error) {
|
||||
rr.Body = b
|
||||
return rr.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func HTTPLogger(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startTime := time.Now()
|
||||
rr := &ResponseRecorder{
|
||||
ResponseWriter: w,
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
handler.ServeHTTP(rr, r)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
logger := log.Info()
|
||||
if rr.StatusCode != http.StatusOK {
|
||||
logger = log.Error().Bytes("body", rr.Body)
|
||||
}
|
||||
|
||||
logger.Str("protocol", "http").
|
||||
Str("method", r.Method).
|
||||
Str("path", r.RequestURI).
|
||||
Int("status", rr.StatusCode).
|
||||
Str("status_text", http.StatusText(rr.StatusCode)).
|
||||
Dur("duration", duration).
|
||||
Msg("Recieved an HTTP request")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/worker"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, store db.Store, td worker.TaskDistributor, userService service.UserService, authService service.AuthService) *Server {
|
||||
config := config.Config{
|
||||
AuthTokenExp: time.Minute,
|
||||
AllowedOrigins: []string{
|
||||
"*",
|
||||
},
|
||||
}
|
||||
|
||||
tokenMaker, err := auth.NewPasetoMaker(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
server, err := NewServer(config, store, td, tokenMaker, userService, authService)
|
||||
require.NoError(t, err)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func newContextWithBearerToken(t *testing.T, tokenMaker auth.Maker, username string, role string, duration time.Duration) context.Context {
|
||||
authToken, _, err := tokenMaker.CreateToken(username, role, duration)
|
||||
require.NoError(t, err)
|
||||
bearerToken := fmt.Sprintf("%s %s", authTypeBearer, authToken)
|
||||
md := metadata.MD{
|
||||
authHeaderKey: []string{
|
||||
bearerToken,
|
||||
},
|
||||
}
|
||||
return metadata.NewIncomingContext(context.Background(), md)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
const (
|
||||
grpcGatewayUserAgentHeader = "grpcgateway-user-agent"
|
||||
userAgentHeader = "user-agent"
|
||||
xForwardedForHeader = "x-forwarded-for"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
UserAgent string
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
func (s *Server) extractMetadata(ctx context.Context) *Metadata {
|
||||
mtdt := Metadata{}
|
||||
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if ok {
|
||||
if userAgent := md.Get(grpcGatewayUserAgentHeader); len(userAgent) > 0 {
|
||||
mtdt.UserAgent = userAgent[0]
|
||||
}
|
||||
if userAgent := md.Get(userAgentHeader); len(userAgent) > 0 {
|
||||
mtdt.UserAgent = userAgent[0]
|
||||
}
|
||||
if clientIPs := md.Get(xForwardedForHeader); len(clientIPs) > 0 {
|
||||
mtdt.ClientIP = clientIPs[0]
|
||||
}
|
||||
}
|
||||
|
||||
if p, ok := peer.FromContext(ctx); ok {
|
||||
mtdt.ClientIP = p.Addr.String()
|
||||
}
|
||||
|
||||
return &mtdt
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/val"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (s *Server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
|
||||
violations := validateCreateUserRequest(req)
|
||||
if violations != nil {
|
||||
return nil, invalidArgumentError(violations)
|
||||
}
|
||||
|
||||
result, err := s.userService.CreateUser(ctx, service.RegisterUserRequest{
|
||||
Username: req.GetUsername(),
|
||||
Password: req.GetPassword(),
|
||||
FullName: req.GetFullName(),
|
||||
Email: req.GetEmail(),
|
||||
})
|
||||
if err != nil {
|
||||
// Map service errors to gRPC status codes
|
||||
if db.ErrorCode(err) == db.UniqueViolation {
|
||||
return nil, status.Errorf(codes.AlreadyExists, "username already exists")
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
||||
}
|
||||
|
||||
resp := &pb.CreateUserResponse{
|
||||
User: convertUser(result.User),
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateCreateUserRequest(req *pb.CreateUserRequest) (violations []*errdetails.BadRequest_FieldViolation) {
|
||||
if err := val.ValidateUsername(req.GetUsername()); err != nil {
|
||||
violations = append(violations, fieldViolation("username", err))
|
||||
}
|
||||
|
||||
if err := val.ValidateFullName(req.GetFullName()); err != nil {
|
||||
violations = append(violations, fieldViolation("full_name", err))
|
||||
}
|
||||
|
||||
if err := val.ValidatePassword(req.GetPassword()); err != nil {
|
||||
violations = append(violations, fieldViolation("password", err))
|
||||
}
|
||||
|
||||
if err := val.ValidateEmail(req.GetEmail()); err != nil {
|
||||
violations = append(violations, fieldViolation("email", err))
|
||||
}
|
||||
return violations
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/security"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
mocksvc "github.com/FernandoJVideira/simple_bank/internal/service/mock"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func randomUser(t *testing.T) (user db.User, password string) {
|
||||
password = testutil.RandomString(6)
|
||||
hashedPassword, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user = db.User{
|
||||
Username: testutil.RandomOwner(),
|
||||
HashedPassword: hashedPassword,
|
||||
Role: role.MemberRole,
|
||||
FullName: testutil.RandomOwner(),
|
||||
Email: testutil.RandomEmail(),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestCreateUserRPC(t *testing.T) {
|
||||
user, password := randomUser(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
req *pb.CreateUserRequest
|
||||
buildStubs func(userService *mocksvc.MockUserService)
|
||||
checkResponse func(t *testing.T, res *pb.CreateUserResponse, err error)
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
req: &pb.CreateUserRequest{
|
||||
Username: user.Username,
|
||||
Password: password,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
arg := service.RegisterUserRequest{
|
||||
Username: user.Username,
|
||||
Password: password,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
userService.EXPECT().
|
||||
CreateUser(gomock.Any(), gomock.Eq(arg)).
|
||||
Times(1).
|
||||
Return(&service.RegisterUserResponse{
|
||||
User: user,
|
||||
}, nil)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Equal(t, user.Username, res.User.Username)
|
||||
require.Equal(t, user.FullName, res.User.FullName)
|
||||
require.Equal(t, user.Email, res.User.Email)
|
||||
require.NotEmpty(t, res.User.CreatedAt)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InternalError",
|
||||
req: &pb.CreateUserRequest{
|
||||
Username: user.Username,
|
||||
Password: password,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
userService.EXPECT().
|
||||
CreateUser(gomock.Any(), gomock.Any()).
|
||||
Times(1).
|
||||
Return(nil, sql.ErrConnDone)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.Internal)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DuplicateUsername",
|
||||
req: &pb.CreateUserRequest{
|
||||
Username: user.Username,
|
||||
Password: password,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
userService.EXPECT().
|
||||
CreateUser(gomock.Any(), gomock.Any()).
|
||||
Times(1).
|
||||
Return(nil, db.ErrUniqueViolation)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.AlreadyExists)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidEmail",
|
||||
req: &pb.CreateUserRequest{
|
||||
Username: user.Username,
|
||||
Password: password,
|
||||
FullName: user.FullName,
|
||||
Email: "invalid-email",
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
userService.EXPECT().
|
||||
CreateUser(gomock.Any(), gomock.Any()).
|
||||
Times(0)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.InvalidArgument)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PasswordTooShort",
|
||||
req: &pb.CreateUserRequest{
|
||||
Username: user.Username,
|
||||
Password: "short",
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
userService.EXPECT().
|
||||
CreateUser(gomock.Any(), gomock.Any()).
|
||||
Times(0)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.InvalidArgument)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
userCtrl := gomock.NewController(t)
|
||||
defer userCtrl.Finish()
|
||||
|
||||
userService := mocksvc.NewMockUserService(userCtrl)
|
||||
|
||||
tc.buildStubs(userService)
|
||||
|
||||
// Start test server and send request
|
||||
s := newTestServer(t, nil, nil, userService, nil)
|
||||
res, err := s.CreateUser(context.Background(), tc.req)
|
||||
|
||||
tc.checkResponse(t, res, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/val"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (s *Server) LoginUser(ctx context.Context, req *pb.LoginUserRequest) (*pb.LoginUserResponse, error) {
|
||||
violations := validateLoginUserRequest(req)
|
||||
if violations != nil {
|
||||
return nil, invalidArgumentError(violations)
|
||||
}
|
||||
|
||||
mtdt := s.extractMetadata(ctx)
|
||||
|
||||
result, err := s.authService.Login(ctx, service.LoginCredentials{
|
||||
Username: req.GetUsername(),
|
||||
Password: req.GetPassword(),
|
||||
UserAgent: mtdt.UserAgent,
|
||||
ClientIP: mtdt.ClientIP,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if errors.Is(err, db.ErrRecordNotFound) || strings.Contains(errMsg, "user not found") {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
if strings.Contains(errMsg, "incorrect password") {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "incorrect password")
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "failed to login: %v", err)
|
||||
}
|
||||
|
||||
resp := convertLoginUserResponse(
|
||||
result.User,
|
||||
result.SessionID,
|
||||
result.AccessToken,
|
||||
result.RefreshToken,
|
||||
result.AccessTokenExpiresAt,
|
||||
result.RefreshTokenExpiresAt,
|
||||
)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TODO: Change to throw "username or password is incorrect" instead of specifying which one is wrong
|
||||
func validateLoginUserRequest(req *pb.LoginUserRequest) (violations []*errdetails.BadRequest_FieldViolation) {
|
||||
if err := val.ValidateUsername(req.GetUsername()); err != nil {
|
||||
violations = append(violations, fieldViolation("username", err))
|
||||
}
|
||||
|
||||
if err := val.ValidatePassword(req.GetPassword()); err != nil {
|
||||
violations = append(violations, fieldViolation("password", err))
|
||||
}
|
||||
return violations
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/val"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (s *Server) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.UpdateUserResponse, error) {
|
||||
authPayload, err := s.authorizeUser(ctx, []string{role.AdminRole, role.MemberRole})
|
||||
if err != nil {
|
||||
return nil, unauthenticatedError(err)
|
||||
}
|
||||
|
||||
if authPayload.Role != role.AdminRole && authPayload.Username != req.GetUsername() {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "cannot update other user's information")
|
||||
}
|
||||
|
||||
violations := validateUpdateUserRequest(req)
|
||||
if violations != nil {
|
||||
return nil, invalidArgumentError(violations)
|
||||
}
|
||||
|
||||
updateReq := service.UserUpdateRequest{
|
||||
Username: req.GetUsername(),
|
||||
}
|
||||
// Set FullName if provided
|
||||
if req.FullName != nil {
|
||||
fullName := req.GetFullName()
|
||||
updateReq.FullName = &fullName
|
||||
}
|
||||
|
||||
// Set Email if provided
|
||||
if req.Email != nil {
|
||||
email := req.GetEmail()
|
||||
updateReq.Email = &email
|
||||
}
|
||||
|
||||
user, err := s.userService.UpdateUser(ctx, updateReq)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRecordNotFound) || strings.Contains(err.Error(), "not found") {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
||||
}
|
||||
resp := &pb.UpdateUserResponse{
|
||||
User: convertUser(*user),
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateUpdateUserRequest(req *pb.UpdateUserRequest) (violations []*errdetails.BadRequest_FieldViolation) {
|
||||
if err := val.ValidateUsername(req.GetUsername()); err != nil {
|
||||
violations = append(violations, fieldViolation("username", err))
|
||||
}
|
||||
|
||||
if req.FullName != nil {
|
||||
if err := val.ValidateFullName(req.GetFullName()); err != nil {
|
||||
violations = append(violations, fieldViolation("full_name", err))
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != nil {
|
||||
if err := val.ValidatePassword(req.GetPassword()); err != nil {
|
||||
violations = append(violations, fieldViolation("password", err))
|
||||
}
|
||||
}
|
||||
|
||||
if req.Email != nil {
|
||||
if err := val.ValidateEmail(req.GetEmail()); err != nil {
|
||||
violations = append(violations, fieldViolation("email", err))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func unauthenticatedError(err error) error {
|
||||
return status.Errorf(codes.Unauthenticated, "unauthenticated: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
mocksvc "github.com/FernandoJVideira/simple_bank/internal/service/mock"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestUpdateUserRPC(t *testing.T) {
|
||||
user, _ := randomUser(t)
|
||||
|
||||
newName := testutil.RandomOwner()
|
||||
newEmail := testutil.RandomEmail()
|
||||
invalidEmail := "invalid-email-format"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
req *pb.UpdateUserRequest
|
||||
buildStubs func(userService *mocksvc.MockUserService)
|
||||
buildContext func(t *testing.T, tokenMaker auth.Maker) context.Context
|
||||
checkResponse func(t *testing.T, res *pb.UpdateUserResponse, err error)
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
req: &pb.UpdateUserRequest{
|
||||
Username: user.Username,
|
||||
FullName: &newName,
|
||||
Email: &newEmail,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
arg := service.UserUpdateRequest{
|
||||
Username: user.Username,
|
||||
FullName: &newName,
|
||||
Email: &newEmail,
|
||||
}
|
||||
|
||||
updatedUser := db.User{
|
||||
Username: user.Username,
|
||||
HashedPassword: user.HashedPassword,
|
||||
FullName: newName,
|
||||
Email: newEmail,
|
||||
}
|
||||
|
||||
userService.EXPECT().
|
||||
UpdateUser(gomock.Any(), gomock.Eq(arg)).
|
||||
Times(1).
|
||||
Return(&updatedUser, nil)
|
||||
},
|
||||
buildContext: func(t *testing.T, tokenMaker auth.Maker) context.Context {
|
||||
return newContextWithBearerToken(t, tokenMaker, user.Username, user.Role, time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
updatedUser := res.GetUser()
|
||||
require.Equal(t, user.Username, updatedUser.Username)
|
||||
require.Equal(t, newName, updatedUser.FullName)
|
||||
require.Equal(t, newEmail, updatedUser.Email)
|
||||
},
|
||||
}, {
|
||||
name: "UserNotFound",
|
||||
req: &pb.UpdateUserRequest{
|
||||
Username: user.Username,
|
||||
FullName: &newName,
|
||||
Email: &newEmail,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
|
||||
userService.EXPECT().
|
||||
UpdateUser(gomock.Any(), gomock.Any()).
|
||||
Times(1).
|
||||
Return(nil, db.ErrRecordNotFound)
|
||||
},
|
||||
buildContext: func(t *testing.T, tokenMaker auth.Maker) context.Context {
|
||||
return newContextWithBearerToken(t, tokenMaker, user.Username, user.Role, time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.NotFound)
|
||||
},
|
||||
}, {
|
||||
name: "ExpiredToken",
|
||||
req: &pb.UpdateUserRequest{
|
||||
Username: user.Username,
|
||||
FullName: &newName,
|
||||
Email: &newEmail,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
|
||||
userService.EXPECT().
|
||||
UpdateUser(gomock.Any(), gomock.Any()).
|
||||
Times(0)
|
||||
},
|
||||
buildContext: func(t *testing.T, tokenMaker auth.Maker) context.Context {
|
||||
return newContextWithBearerToken(t, tokenMaker, user.Username, user.Role, -time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.Unauthenticated)
|
||||
},
|
||||
}, {
|
||||
name: "NoAuthorizationHeader",
|
||||
req: &pb.UpdateUserRequest{
|
||||
Username: user.Username,
|
||||
FullName: &newName,
|
||||
Email: &newEmail,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
|
||||
userService.EXPECT().
|
||||
UpdateUser(gomock.Any(), gomock.Any()).
|
||||
Times(0)
|
||||
},
|
||||
buildContext: func(t *testing.T, tokenMaker auth.Maker) context.Context {
|
||||
return context.Background()
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.Unauthenticated)
|
||||
},
|
||||
}, {
|
||||
name: "InvalidEmail",
|
||||
req: &pb.UpdateUserRequest{
|
||||
Username: user.Username,
|
||||
FullName: &newName,
|
||||
Email: &invalidEmail,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
|
||||
userService.EXPECT().
|
||||
UpdateUser(gomock.Any(), gomock.Any()).
|
||||
Times(0)
|
||||
},
|
||||
buildContext: func(t *testing.T, tokenMaker auth.Maker) context.Context {
|
||||
return newContextWithBearerToken(t, tokenMaker, user.Username, user.Role, time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, st.Code(), codes.InvalidArgument)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
userCtrl := gomock.NewController(t)
|
||||
defer userCtrl.Finish()
|
||||
|
||||
userService := mocksvc.NewMockUserService(userCtrl)
|
||||
|
||||
tc.buildStubs(userService)
|
||||
|
||||
// Start test server and send request
|
||||
s := newTestServer(t, nil, nil, userService, nil)
|
||||
|
||||
ctx := tc.buildContext(t, s.tokenMaker)
|
||||
|
||||
// Call UpdateUser RPC
|
||||
res, err := s.UpdateUser(ctx, tc.req)
|
||||
|
||||
tc.checkResponse(t, res, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/val"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (s *Server) VerifyEmail(ctx context.Context, req *pb.VerifyEmailRequest) (*pb.VerifyEmailResponse, error) {
|
||||
violations := validateVerifyEmailRequest(req)
|
||||
if violations != nil {
|
||||
return nil, invalidArgumentError(violations)
|
||||
}
|
||||
|
||||
result, err := s.userService.VerifyEmail(ctx, service.EmailVerificationRequest{
|
||||
EmailID: req.GetId(),
|
||||
SecretCode: req.GetCode(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "failed to verify email")
|
||||
}
|
||||
|
||||
resp := &pb.VerifyEmailResponse{
|
||||
IsVerified: result.User.IsEmailVerified,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateVerifyEmailRequest(req *pb.VerifyEmailRequest) (violations []*errdetails.BadRequest_FieldViolation) {
|
||||
if err := val.ValidateEmailID(req.GetId()); err != nil {
|
||||
violations = append(violations, fieldViolation("id", err))
|
||||
}
|
||||
|
||||
if err := val.ValidateVerificationCode(req.GetCode()); err != nil {
|
||||
violations = append(violations, fieldViolation("secret_code", err))
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package gapi implements the gRPC server for the Simple Bank application.
|
||||
package gapi
|
||||
|
||||
import (
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/worker"
|
||||
"github.com/FernandoJVideira/simple_bank/pb"
|
||||
)
|
||||
|
||||
// Server serves gRPC requests for our banking service.
|
||||
type Server struct {
|
||||
pb.UnimplementedSimpleBankServer // Embedding for forward compatibility
|
||||
config config.Config
|
||||
store db.Store
|
||||
tokenMaker auth.Maker
|
||||
taskDistributor worker.TaskDistributor
|
||||
userService service.UserService
|
||||
authService service.AuthService
|
||||
}
|
||||
|
||||
// NewServer creates a new gRPC server instance.
|
||||
func NewServer(
|
||||
config config.Config,
|
||||
store db.Store,
|
||||
taskDistributor worker.TaskDistributor,
|
||||
tokenMaker auth.Maker,
|
||||
userService service.UserService,
|
||||
authService service.AuthService,
|
||||
) (*Server, error) {
|
||||
server := &Server{
|
||||
config: config,
|
||||
store: store,
|
||||
tokenMaker: tokenMaker,
|
||||
taskDistributor: taskDistributor,
|
||||
userService: userService,
|
||||
authService: authService,
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
Reference in new issue
Block a user