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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"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/gin-gonic/gin"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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 TestMain(m *testing.M) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/handlers/http/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
authHeaderKey = "authorization"
|
||||
authTypeBearer = "bearer"
|
||||
authorizationPayloadKey = "authorization_payload"
|
||||
)
|
||||
|
||||
func authMiddleware(tokenMaker auth.Maker, accessibleRoles []string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
authorizationHeader := ctx.GetHeader(authHeaderKey)
|
||||
if len(authorizationHeader) == 0 {
|
||||
err := errors.New("authorization header is not provided")
|
||||
resp := response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Split
|
||||
fields := strings.Fields(authorizationHeader)
|
||||
if len(fields) < 2 {
|
||||
err := errors.New("invalid authorization header format")
|
||||
resp := response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, resp)
|
||||
}
|
||||
|
||||
authType := strings.ToLower(fields[0])
|
||||
if authType != authTypeBearer {
|
||||
err := fmt.Errorf("unsupported authorization type %s", authType)
|
||||
resp := response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, resp)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken := fields[1]
|
||||
payload, err := tokenMaker.VerifyToken(accessToken)
|
||||
if err != nil {
|
||||
resp := response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, resp)
|
||||
return
|
||||
}
|
||||
|
||||
if !role.HasPermission(payload.Role, accessibleRoles) {
|
||||
err := fmt.Errorf("permission denied")
|
||||
resp := response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.AbortWithStatusJSON(http.StatusForbidden, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Set(authorizationPayloadKey, payload)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
mocksvc "github.com/FernandoJVideira/simple_bank/internal/service/mock"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func addAuth(t *testing.T, request *http.Request, tokenMaker auth.Maker, authType string, username string, role string, duration time.Duration) {
|
||||
token, payload, err := tokenMaker.CreateToken(username, role, duration)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
authorizationHeader := fmt.Sprintf("%s %s", authType, token)
|
||||
request.Header.Set(authHeaderKey, authorizationHeader)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
|
||||
username := testutil.RandomOwner()
|
||||
testRole := role.MemberRole
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
setupAuth func(t *testing.T, request *http.Request, tokenMaker auth.Maker)
|
||||
checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder)
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
setupAuth: func(t *testing.T, request *http.Request, tokenMaker auth.Maker) {
|
||||
addAuth(t, request, tokenMaker, authTypeBearer, username, testRole, time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NoAuthorization",
|
||||
setupAuth: func(t *testing.T, request *http.Request, tokenMaker auth.Maker) {
|
||||
// Do not add any authorization header
|
||||
},
|
||||
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UnsupportedAuthType",
|
||||
setupAuth: func(t *testing.T, request *http.Request, tokenMaker auth.Maker) {
|
||||
addAuth(t, request, tokenMaker, "UnsupportedAuthType", username, testRole, time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidAuthFormat",
|
||||
setupAuth: func(t *testing.T, request *http.Request, tokenMaker auth.Maker) {
|
||||
addAuth(t, request, tokenMaker, "", username, testRole, time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ExpiredAtToken",
|
||||
setupAuth: func(t *testing.T, request *http.Request, tokenMaker auth.Maker) {
|
||||
addAuth(t, request, tokenMaker, authTypeBearer, username, testRole, -time.Minute)
|
||||
},
|
||||
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
authCtrl := gomock.NewController(t)
|
||||
defer authCtrl.Finish()
|
||||
|
||||
authService := mocksvc.NewMockAuthService(authCtrl)
|
||||
|
||||
s := newTestServer(t, nil, nil, nil, authService)
|
||||
|
||||
authPath := "/auth"
|
||||
s.router.GET(
|
||||
authPath,
|
||||
authMiddleware(s.tokenMaker, []string{role.AdminRole, role.MemberRole}),
|
||||
func(ctx *gin.Context) {
|
||||
ctx.JSON(200, gin.H{})
|
||||
},
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
request, err := http.NewRequest(http.MethodGet, authPath, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
tc.setupAuth(t, request, s.tokenMaker)
|
||||
s.router.ServeHTTP(recorder, request)
|
||||
tc.checkResponse(t, recorder)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package response
|
||||
|
||||
type JSONResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package api provides the HTTP server and routing for the banking service.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/worker"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// Server serves HTTP requests for our banking service.
|
||||
type Server struct {
|
||||
config config.Config
|
||||
store db.Store
|
||||
tokenMaker auth.Maker
|
||||
router *gin.Engine
|
||||
taskDisributor worker.TaskDistributor
|
||||
httpServer *http.Server
|
||||
userService service.UserService
|
||||
authService service.AuthService
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
config config.Config,
|
||||
store db.Store,
|
||||
taskDistributor worker.TaskDistributor,
|
||||
tokenMaker auth.Maker,
|
||||
userService service.UserService,
|
||||
authService service.AuthService,
|
||||
) (*Server, error) {
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
store: store,
|
||||
tokenMaker: tokenMaker,
|
||||
taskDisributor: taskDistributor,
|
||||
userService: userService,
|
||||
authService: authService,
|
||||
}
|
||||
|
||||
// if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
// err := v.RegisterValidation("currency", validCurrency)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// }
|
||||
|
||||
s.setupRouter(config)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) setupRouter(config config.Config) {
|
||||
router := gin.Default()
|
||||
|
||||
// Configure CORS middleware
|
||||
router.Use(cors.New(cors.Config{
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
http.MethodOptions,
|
||||
},
|
||||
AllowHeaders: []string{
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CSRF-Token",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// Add routes
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
auth := v1.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", s.loginUser)
|
||||
auth.POST("/register", s.createUser)
|
||||
auth.GET("/verify", s.verifyEmail)
|
||||
auth.POST("/refresh", s.renewAccessToken)
|
||||
}
|
||||
|
||||
user := v1.Group("/users")
|
||||
{
|
||||
me := user.Group("/me").Use(authMiddleware(s.tokenMaker, []string{role.AdminRole, role.MemberRole}))
|
||||
{
|
||||
me.GET("/", s.getCurrentUser)
|
||||
me.PUT("/", s.updateUser)
|
||||
me.PUT("/password", s.changePassword)
|
||||
}
|
||||
|
||||
user.GET("/:username", s.getUser)
|
||||
}
|
||||
}
|
||||
s.router = router
|
||||
}
|
||||
|
||||
// Start runs the HTTP server on a specific address.
|
||||
func (s *Server) Start(address string) error {
|
||||
s.httpServer = &http.Server{
|
||||
Addr: address,
|
||||
Handler: s.router,
|
||||
}
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
log.Info().Msg("shutting down Gin HTTP server")
|
||||
if s.httpServer != nil {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/handlers/http/response"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type renewAccessTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type renewAccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessTokenExpiresAt time.Time `json:"access_token_expires_at"`
|
||||
}
|
||||
|
||||
func (s *Server) renewAccessToken(ctx *gin.Context) {
|
||||
var req renewAccessTokenRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Call auth service - all business logic moved there
|
||||
result, err := s.authService.RenewAccessToken(ctx, service.TokenRenewalRequest{
|
||||
RefreshToken: req.RefreshToken,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
statusCode := http.StatusInternalServerError
|
||||
errMsg := err.Error()
|
||||
|
||||
// Map service errors to HTTP codes
|
||||
if errors.Is(err, db.ErrRecordNotFound) || strings.Contains(errMsg, "session not found") {
|
||||
statusCode = http.StatusNotFound
|
||||
} else if strings.Contains(errMsg, "invalid") ||
|
||||
strings.Contains(errMsg, "blocked") ||
|
||||
strings.Contains(errMsg, "expired") ||
|
||||
strings.Contains(errMsg, "mismatched") {
|
||||
statusCode = http.StatusUnauthorized
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
}
|
||||
ctx.JSON(statusCode, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "Access token renewed successfully",
|
||||
Data: renewAccessTokenResponse{
|
||||
AccessToken: result.AccessToken,
|
||||
AccessTokenExpiresAt: result.AccessTokenExpiresAt,
|
||||
},
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/FernandoJVideira/simple_bank/docs"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/auth"
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/handlers/http/response"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username" binding:"required,alphanum"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
FullName string `json:"full_name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
Username string `json:"username"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
PasswordChangedAt time.Time `json:"password_changed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func newUserResponse(user db.User) userResponse {
|
||||
return userResponse{
|
||||
Username: user.Username,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
PasswordChangedAt: user.PasswordChangedAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser godoc
|
||||
// @Summary Create a new user
|
||||
// @Description Register a new user account
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body createUserRequest true "User registration details"
|
||||
// @Success 201 {object} response.JSONResponse{data=userResponse}
|
||||
// @Failure 400 {object} response.JSONResponse
|
||||
// @Failure 500 {object} response.JSONResponse
|
||||
// @Router /v1/auth/register [post]
|
||||
func (s *Server) createUser(ctx *gin.Context) {
|
||||
var req createUserRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.userService.CreateUser(ctx, service.RegisterUserRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
FullName: req.FullName,
|
||||
Email: req.Email,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
if db.ErrorCode(err) == db.UniqueViolation {
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: errMessage,
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "User created successfully",
|
||||
Data: newUserResponse(result.User),
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
type getUserRequest struct {
|
||||
Username string `uri:"username" binding:"required,alphanum"`
|
||||
}
|
||||
|
||||
func (s *Server) getUser(ctx *gin.Context) {
|
||||
var req getUserRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
if err := ctx.ShouldBindUri(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.userService.GetUser(ctx, req.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRecordNotFound) {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "User not found",
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, resp)
|
||||
return
|
||||
}
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "User retrieved successfully",
|
||||
Data: newUserResponse(*user),
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) getCurrentUser(ctx *gin.Context) {
|
||||
var resp response.JSONResponse
|
||||
|
||||
authPayload := ctx.MustGet(authorizationPayloadKey).(*auth.Payload)
|
||||
|
||||
user, err := s.userService.GetUser(ctx, authPayload.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRecordNotFound) {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "User not found",
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, resp)
|
||||
return
|
||||
}
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "Current user retrieved successfully",
|
||||
Data: newUserResponse(*user),
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Username string `json:"username" binding:"required,alphanum"`
|
||||
FullName string `json:"full_name" binding:"omitempty,min=3"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(ctx *gin.Context) {
|
||||
var req UpdateUserRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
authPayload := ctx.MustGet(authorizationPayloadKey).(*auth.Payload)
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
if authPayload.Username != req.Username {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "Cannot update other user's information",
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, resp)
|
||||
return
|
||||
}
|
||||
|
||||
params := service.UserUpdateRequest{
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.FullName) != "" {
|
||||
params.FullName = &req.FullName
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Email) != "" {
|
||||
params.Email = &req.Email
|
||||
}
|
||||
|
||||
user, err := s.userService.UpdateUser(ctx, params)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRecordNotFound) {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "User not found",
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, resp)
|
||||
return
|
||||
}
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "User updated successfully",
|
||||
Data: newUserResponse(*user),
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required,min=6"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=NewPassword"`
|
||||
}
|
||||
|
||||
func (s *Server) changePassword(ctx *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
authPayload := ctx.MustGet(authorizationPayloadKey).(*auth.Payload)
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
updUser, err := s.userService.UpdatePassword(ctx, service.PasswordUpdateRequest{
|
||||
Username: authPayload.Username,
|
||||
OldPassword: req.CurrentPassword,
|
||||
NewPassword: req.NewPassword,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRecordNotFound) {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "User not found",
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, resp)
|
||||
return
|
||||
}
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "Password changed successfully",
|
||||
Data: newUserResponse(*updUser),
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type loginUserRequest struct {
|
||||
Username string `json:"username" binding:"required,alphanum"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type loginUserResponse struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessTokenExpiresAt time.Time `json:"access_token_expires_at"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"`
|
||||
User userResponse `json:"user"`
|
||||
}
|
||||
|
||||
func (s *Server) loginUser(ctx *gin.Context) {
|
||||
var req loginUserRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
loginRes, err := s.authService.Login(ctx, service.LoginCredentials{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
UserAgent: ctx.Request.UserAgent(),
|
||||
ClientIP: ctx.ClientIP(),
|
||||
})
|
||||
if err != nil {
|
||||
statusCode := http.StatusInternalServerError
|
||||
errMsg := err.Error()
|
||||
|
||||
// Map service errors to HTTP codes
|
||||
if errors.Is(err, db.ErrRecordNotFound) || strings.Contains(errMsg, "user not found") {
|
||||
statusCode = http.StatusNotFound
|
||||
} else if strings.Contains(errMsg, "incorrect username or password") {
|
||||
statusCode = http.StatusUnauthorized
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
}
|
||||
ctx.JSON(statusCode, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "User logged in successfully",
|
||||
Data: loginUserResponse{
|
||||
SessionID: loginRes.SessionID,
|
||||
AccessToken: loginRes.AccessToken,
|
||||
AccessTokenExpiresAt: loginRes.AccessTokenExpiresAt,
|
||||
RefreshToken: loginRes.RefreshToken,
|
||||
RefreshTokenExpiresAt: loginRes.RefreshTokenExpiresAt,
|
||||
User: newUserResponse(loginRes.User),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type verifyEmailRequest struct {
|
||||
ID int64 `form:"id" binding:"required,min=1"`
|
||||
Code string `form:"code" binding:"required,min=32,max=128"`
|
||||
}
|
||||
|
||||
type verifyEmailResponse struct {
|
||||
IsVerified bool `json:"is_verified"`
|
||||
}
|
||||
|
||||
func (s *Server) verifyEmail(ctx *gin.Context) {
|
||||
var req verifyEmailRequest
|
||||
var resp response.JSONResponse
|
||||
|
||||
if err := ctx.ShouldBindQuery(&req); err != nil {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.userService.VerifyEmail(ctx, service.EmailVerificationRequest{
|
||||
EmailID: req.ID,
|
||||
SecretCode: req.Code,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrForeignKeyViolation) {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "Email verification record not found",
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, resp)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, db.ErrCheckViolation) {
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: "Invalid verification code",
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp = response.JSONResponse{
|
||||
Success: true,
|
||||
Message: "Email verified successfully",
|
||||
Data: verifyEmailResponse{
|
||||
IsVerified: result.User.IsEmailVerified,
|
||||
},
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/handlers/http/response"
|
||||
"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/pkg/testutil"
|
||||
)
|
||||
|
||||
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 TestCreateUserAPI(t *testing.T) {
|
||||
user, password := randomUser(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
body gin.H
|
||||
buildStubs func(userService *mocksvc.MockUserService)
|
||||
checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder)
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
body: gin.H{
|
||||
"username": user.Username,
|
||||
"password": password,
|
||||
"full_name": user.FullName,
|
||||
"email": user.Email,
|
||||
},
|
||||
buildStubs: func(userService *mocksvc.MockUserService) {
|
||||
|
||||
servcArg := service.RegisterUserRequest{
|
||||
Username: user.Username,
|
||||
Password: password,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
userService.EXPECT().
|
||||
CreateUser(gomock.Any(), servcArg).
|
||||
Times(1).
|
||||
Return(&service.RegisterUserResponse{
|
||||
User: user,
|
||||
}, nil)
|
||||
},
|
||||
checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
require.Equal(t, http.StatusCreated, recorder.Code)
|
||||
requireBodyMatchUser(t, recorder.Body, user)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range testCases {
|
||||
tc := testCases[i]
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
storeCtrl := gomock.NewController(t)
|
||||
defer storeCtrl.Finish()
|
||||
|
||||
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)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
data, err := json.Marshal(tc.body)
|
||||
require.NoError(t, err)
|
||||
|
||||
url := "/api/v1/auth/register"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
|
||||
s.router.ServeHTTP(recorder, req)
|
||||
tc.checkResponse(t, recorder)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func requireBodyMatchUser(t *testing.T, body *bytes.Buffer, user db.User) {
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var gotUser db.User
|
||||
var resp response.JSONResponse
|
||||
|
||||
err = json.Unmarshal(data, &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err = json.Marshal(resp.Data)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = json.Unmarshal(data, &gotUser)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.Username, gotUser.Username)
|
||||
require.Equal(t, user.FullName, gotUser.FullName)
|
||||
require.Equal(t, user.Email, gotUser.Email)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package api
|
||||
|
||||
// (import (
|
||||
// "github.com/FernandoJVideira/simple_bank/util"
|
||||
// "github.com/go-playground/validator/v10"
|
||||
// )
|
||||
|
||||
// var validCurrency validator.Func = func(fieldlevel validator.FieldLevel) bool {
|
||||
// if currency, ok := fieldlevel.Field().Interface().(string); ok {
|
||||
// return util.IsSupportedCurrency(currency)
|
||||
// }
|
||||
// return false
|
||||
// })
|
||||
Reference in new issue
Block a user