feat: 0.0.1
This commit is contained in:
146 files changed
+10444
No files matched your search
@@ -0,0 +1,128 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JWTMaker is a JSON Web Token maker
|
||||
type JWTMaker struct {
|
||||
secretKey string
|
||||
config config.Config
|
||||
}
|
||||
|
||||
func NewJWTClaims(payload *Payload, config config.Config) jwt.Claims {
|
||||
return &jwt.MapClaims{
|
||||
"iat": jwt.NewNumericDate(payload.IssuedAt),
|
||||
"iss": config.TokenISS,
|
||||
"exp": jwt.NewNumericDate(payload.ExpiredAt),
|
||||
"nbf": jwt.NewNumericDate(payload.IssuedAt),
|
||||
"aud": []string{config.TokenAUD},
|
||||
"sub": payload.Username,
|
||||
"role": payload.Role,
|
||||
"jti": payload.ID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewJWTMaker creates a new JWTMaker
|
||||
|
||||
func NewJWTMaker(config config.Config) (Maker, error) {
|
||||
if len(config.TokenKey) < config.MinTokenSize {
|
||||
return nil, fmt.Errorf("invalid key size: must be at least %d characters", config.MinTokenSize)
|
||||
}
|
||||
return &JWTMaker{secretKey: config.TokenKey, config: config}, nil
|
||||
}
|
||||
|
||||
func (maker *JWTMaker) CreateToken(username string, role string, duration time.Duration) (string, *Payload, error) {
|
||||
payload, err := NewPayload(username, role, duration)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, NewJWTClaims(payload, maker.config))
|
||||
|
||||
signedString, err := jwtToken.SignedString([]byte(maker.secretKey))
|
||||
|
||||
return signedString, payload, err
|
||||
}
|
||||
|
||||
func (maker *JWTMaker) VerifyToken(token string) (*Payload, error) {
|
||||
payloadClaims := jwt.MapClaims{}
|
||||
keyFunc := func(token *jwt.Token) (any, error) {
|
||||
_, ok := token.Method.(*jwt.SigningMethodHMAC)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return []byte(maker.secretKey), nil
|
||||
}
|
||||
|
||||
jwtToken, err := jwt.ParseWithClaims(token, payloadClaims, keyFunc)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrExpiredToken
|
||||
} else if errors.Is(err, ErrInvalidToken) {
|
||||
return nil, ErrInvalidToken
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
payloadClaims, ok := jwtToken.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
payload, err := parseTypes(payloadClaims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseTypes(claims jwt.MapClaims) (*Payload, error) {
|
||||
// Safe type assertions with error checking
|
||||
jti, ok := claims["jti"].(string)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(jti)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
username, ok := claims["sub"].(string)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
role, ok := claims["role"].(string)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Parse numeric dates
|
||||
nbf, ok := claims["nbf"].(float64)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return &Payload{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Role: role,
|
||||
IssuedAt: time.Unix(int64(nbf), 0),
|
||||
ExpiredAt: time.Unix(int64(exp), 0),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJWTMaker(t *testing.T) {
|
||||
config, err := config.LoadTestConfig("../../")
|
||||
require.NoError(t, err)
|
||||
|
||||
maker, err := NewJWTMaker(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
username := testutil.RandomOwner()
|
||||
duration := time.Minute
|
||||
role := role.MemberRole
|
||||
|
||||
issuedAt := time.Now()
|
||||
expiredAt := issuedAt.Add(duration)
|
||||
|
||||
token, payload, err := maker.CreateToken(username, role, duration)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
payload, err = maker.VerifyToken(token)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
require.NotZero(t, payload.ID)
|
||||
require.Equal(t, username, payload.Username)
|
||||
require.Equal(t, role, payload.Role)
|
||||
require.WithinDuration(t, issuedAt, payload.IssuedAt, time.Second)
|
||||
require.WithinDuration(t, expiredAt, payload.ExpiredAt, time.Second)
|
||||
}
|
||||
|
||||
func TestExpiredJWTToken(t *testing.T) {
|
||||
config, err := config.LoadTestConfig("../../")
|
||||
require.NoError(t, err)
|
||||
|
||||
maker, err := NewJWTMaker(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
username := testutil.RandomOwner()
|
||||
duration := -time.Minute
|
||||
|
||||
token, payload, err := maker.CreateToken(username, role.MemberRole, duration)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
payload, err = maker.VerifyToken(token)
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, ErrExpiredToken.Error())
|
||||
require.Nil(t, payload)
|
||||
}
|
||||
|
||||
func TestInvalidJWTTokenAlgNone(t *testing.T) {
|
||||
config, err := config.LoadTestConfig("../../")
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err := NewPayload(testutil.RandomOwner(), role.MemberRole, time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodNone, NewJWTClaims(payload, config))
|
||||
token, err := jwtToken.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
require.NoError(t, err)
|
||||
|
||||
maker, err := NewJWTMaker(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload, err = maker.VerifyToken(token)
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, ErrInvalidToken.Error())
|
||||
require.Nil(t, payload)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// Maker is an interface for managing tokens
|
||||
type Maker interface {
|
||||
// CreateToken creates a new token for a specific username and duration
|
||||
CreateToken(username string, role string, duration time.Duration) (string, *Payload, error)
|
||||
// VerifyToken checks if the token is valid or not
|
||||
VerifyToken(token string) (*Payload, error)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aidanwoods.dev/go-paseto"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasetoMaker struct {
|
||||
config config.Config
|
||||
symmetricKey paseto.V4SymmetricKey
|
||||
implicit []byte
|
||||
}
|
||||
|
||||
func NewPasetoMaker(config config.Config) (Maker, error) {
|
||||
maker := &PasetoMaker{
|
||||
config: config,
|
||||
symmetricKey: paseto.NewV4SymmetricKey(),
|
||||
implicit: []byte("implicit"),
|
||||
}
|
||||
return maker, nil
|
||||
}
|
||||
|
||||
func (maker *PasetoMaker) CreateToken(username string, role string, duration time.Duration) (string, *Payload, error) {
|
||||
payload, err := NewPayload(username, role, duration)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
token := paseto.NewToken()
|
||||
token.SetIssuedAt(payload.IssuedAt)
|
||||
token.SetExpiration(payload.ExpiredAt)
|
||||
token.SetString("role", payload.Role)
|
||||
token.SetAudience(maker.config.TokenAUD)
|
||||
token.SetIssuer(maker.config.TokenISS)
|
||||
token.SetSubject(payload.Username)
|
||||
token.SetJti(payload.ID.String())
|
||||
|
||||
signedToken := token.V4Encrypt(maker.symmetricKey, maker.implicit)
|
||||
|
||||
return signedToken, payload, nil
|
||||
}
|
||||
|
||||
func (maker *PasetoMaker) VerifyToken(token string) (*Payload, error) {
|
||||
parser := paseto.NewParser()
|
||||
parser.AddRule(paseto.NotExpired())
|
||||
|
||||
parsedToken, err := parser.ParseV4Local(maker.symmetricKey, token, maker.implicit)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "token has expired") {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
payload, err := parseClaims(parsedToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseClaims(t *paseto.Token) (*Payload, error) {
|
||||
id, err := t.GetJti()
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
username, err := t.GetSubject()
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
role, err := t.GetString("role")
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
issuedAt, err := t.GetIssuedAt()
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
expiredAt, err := t.GetExpiration()
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
payload := &Payload{
|
||||
ID: uuid.MustParse(id),
|
||||
Username: username,
|
||||
Role: role,
|
||||
IssuedAt: issuedAt,
|
||||
ExpiredAt: expiredAt,
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/domain/role"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPasetoMaker(t *testing.T) {
|
||||
config, err := config.LoadTestConfig("../../")
|
||||
require.NoError(t, err)
|
||||
|
||||
maker, err := NewPasetoMaker(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
username := testutil.RandomOwner()
|
||||
duration := time.Minute
|
||||
|
||||
issuedAt := time.Now()
|
||||
expiredAt := issuedAt.Add(duration)
|
||||
role := role.MemberRole
|
||||
|
||||
token, payload, err := maker.CreateToken(username, role, duration)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
payload, err = maker.VerifyToken(token)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
require.NotZero(t, payload.ID)
|
||||
require.Equal(t, username, payload.Username)
|
||||
require.Equal(t, role, payload.Role)
|
||||
require.WithinDuration(t, issuedAt, payload.IssuedAt, time.Second)
|
||||
require.WithinDuration(t, expiredAt, payload.ExpiredAt, time.Second)
|
||||
}
|
||||
|
||||
func TestExpiredPasetoToken(t *testing.T) {
|
||||
config, err := config.LoadTestConfig("../../")
|
||||
require.NoError(t, err)
|
||||
|
||||
maker, err := NewPasetoMaker(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
username := testutil.RandomOwner()
|
||||
duration := -time.Minute
|
||||
|
||||
token, payload, err := maker.CreateToken(username, role.MemberRole, duration)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
require.NotEmpty(t, payload)
|
||||
|
||||
payload, err = maker.VerifyToken(token)
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, ErrExpiredToken.Error())
|
||||
require.Nil(t, payload)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrExpiredToken = fmt.Errorf("token has expired")
|
||||
ErrInvalidToken = fmt.Errorf("token is invalid")
|
||||
)
|
||||
|
||||
// Payload contains the payload data of the token
|
||||
type Payload struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"` // or UserID
|
||||
Role string `json:"role"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiredAt time.Time `json:"expired_at"`
|
||||
}
|
||||
|
||||
// NewPayload creates a new token payload with a specific username and duration
|
||||
func NewPayload(username string, role string, duration time.Duration) (*Payload, error) {
|
||||
tokenID, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := &Payload{
|
||||
ID: tokenID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
IssuedAt: time.Now(),
|
||||
ExpiredAt: time.Now().Add(duration),
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config stores all configuration of the application.
|
||||
// The values are read from environment variables or a config file.
|
||||
type Config struct {
|
||||
// Server settings
|
||||
ServiceName string `mapstructure:"SERVICE_NAME"`
|
||||
FrontendURL string `mapstructure:"FRONTEND_URL"`
|
||||
AllowedOrigins []string `mapstructure:"ALLOWED_ORIGINS"`
|
||||
DBAddr string `mapstructure:"DB_ADDR"`
|
||||
RedisAddr string `mapstructure:"REDIS_ADDR"`
|
||||
RedisPassword string `mapstructure:"REDIS_PASSWORD"`
|
||||
RedisDB int `mapstructure:"REDIS_DB"`
|
||||
MigrationURL string `mapstructure:"MIGRATIONS_URL"`
|
||||
HTTPServerAddr string `mapstructure:"HTTP_SERVER_ADDR"`
|
||||
GrpcServerAddr string `mapstructure:"GRPC_SERVER_ADDR"`
|
||||
AuthTokenExp time.Duration `mapstructure:"AUTH_TOKEN_EXP"`
|
||||
RefreshTokenExp time.Duration `mapstructure:"REFRESH_TOKEN_EXP"`
|
||||
MinTokenSize int `mapstructure:"AUTH_TOKEN_MIN_KEY_SIZE"`
|
||||
TokenKey string `mapstructure:"AUTH_TOKEN_SECRET"`
|
||||
TokenISS string `mapstructure:"AUTH_TOKEN_ISS"`
|
||||
TokenAUD string `mapstructure:"AUTH_TOKEN_AUD"`
|
||||
EmailSender string `mapstructure:"EMAIL_FROM"`
|
||||
EmailSenderName string `mapstructure:"EMAIL_FROM_NAME"`
|
||||
EmailPassword string `mapstructure:"EMAIL_SMTP_PASSWORD"`
|
||||
Environment string `mapstructure:"ENVIRONMENT"`
|
||||
}
|
||||
|
||||
// LoadConfig reads configuration from file or environment variables.
|
||||
func LoadConfig(path string) (config Config, err error) {
|
||||
viper.AddConfigPath(path)
|
||||
viper.SetConfigName("api")
|
||||
viper.SetConfigType("env") // could be json, toml, yaml, env, etc.
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
err = viper.ReadInConfig()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = viper.Unmarshal(&config)
|
||||
return
|
||||
}
|
||||
|
||||
// LoadTestConfig reads test configuration from file or environment variables.
|
||||
func LoadTestConfig(path string) (config Config, err error) {
|
||||
viper.AddConfigPath(path)
|
||||
viper.SetConfigName("api_test")
|
||||
viper.SetConfigType("env") // could be json, toml, yaml, env, etc.
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
err = viper.ReadInConfig()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = viper.Unmarshal(&config)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/FernandoJVideira/simple_bank/internal/db/sqlc (interfaces: Store)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen --package mockdb --destination internal/db/mock/store.go github.com/FernandoJVideira/simple_bank/internal/db/sqlc Store
|
||||
//
|
||||
|
||||
// Package mockdb is a generated GoMock package.
|
||||
package mockdb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
uuid "github.com/google/uuid"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockStore is a mock of Store interface.
|
||||
type MockStore struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockStoreMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockStoreMockRecorder is the mock recorder for MockStore.
|
||||
type MockStoreMockRecorder struct {
|
||||
mock *MockStore
|
||||
}
|
||||
|
||||
// NewMockStore creates a new mock instance.
|
||||
func NewMockStore(ctrl *gomock.Controller) *MockStore {
|
||||
mock := &MockStore{ctrl: ctrl}
|
||||
mock.recorder = &MockStoreMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// CreateSession mocks base method.
|
||||
func (m *MockStore) CreateSession(ctx context.Context, arg db.CreateSessionParams) (db.Session, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateSession", ctx, arg)
|
||||
ret0, _ := ret[0].(db.Session)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateSession indicates an expected call of CreateSession.
|
||||
func (mr *MockStoreMockRecorder) CreateSession(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockStore)(nil).CreateSession), ctx, arg)
|
||||
}
|
||||
|
||||
// CreateUser mocks base method.
|
||||
func (m *MockStore) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateUser", ctx, arg)
|
||||
ret0, _ := ret[0].(db.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateUser indicates an expected call of CreateUser.
|
||||
func (mr *MockStoreMockRecorder) CreateUser(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockStore)(nil).CreateUser), ctx, arg)
|
||||
}
|
||||
|
||||
// CreateUserTx mocks base method.
|
||||
func (m *MockStore) CreateUserTx(ctx context.Context, arg db.CreateUserTxParams) (db.CreateUserTxResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateUserTx", ctx, arg)
|
||||
ret0, _ := ret[0].(db.CreateUserTxResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateUserTx indicates an expected call of CreateUserTx.
|
||||
func (mr *MockStoreMockRecorder) CreateUserTx(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserTx", reflect.TypeOf((*MockStore)(nil).CreateUserTx), ctx, arg)
|
||||
}
|
||||
|
||||
// CreateVerifyEmail mocks base method.
|
||||
func (m *MockStore) CreateVerifyEmail(ctx context.Context, arg db.CreateVerifyEmailParams) (db.VerifyEmail, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateVerifyEmail", ctx, arg)
|
||||
ret0, _ := ret[0].(db.VerifyEmail)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateVerifyEmail indicates an expected call of CreateVerifyEmail.
|
||||
func (mr *MockStoreMockRecorder) CreateVerifyEmail(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateVerifyEmail", reflect.TypeOf((*MockStore)(nil).CreateVerifyEmail), ctx, arg)
|
||||
}
|
||||
|
||||
// GetSession mocks base method.
|
||||
func (m *MockStore) GetSession(ctx context.Context, id uuid.UUID) (db.Session, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetSession", ctx, id)
|
||||
ret0, _ := ret[0].(db.Session)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetSession indicates an expected call of GetSession.
|
||||
func (mr *MockStoreMockRecorder) GetSession(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSession", reflect.TypeOf((*MockStore)(nil).GetSession), ctx, id)
|
||||
}
|
||||
|
||||
// GetUser mocks base method.
|
||||
func (m *MockStore) GetUser(ctx context.Context, username string) (db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUser", ctx, username)
|
||||
ret0, _ := ret[0].(db.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUser indicates an expected call of GetUser.
|
||||
func (mr *MockStoreMockRecorder) GetUser(ctx, username any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockStore)(nil).GetUser), ctx, username)
|
||||
}
|
||||
|
||||
// UpdateUser mocks base method.
|
||||
func (m *MockStore) UpdateUser(ctx context.Context, arg db.UpdateUserParams) (db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUser", ctx, arg)
|
||||
ret0, _ := ret[0].(db.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUser indicates an expected call of UpdateUser.
|
||||
func (mr *MockStoreMockRecorder) UpdateUser(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockStore)(nil).UpdateUser), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateVerifyEmail mocks base method.
|
||||
func (m *MockStore) UpdateVerifyEmail(ctx context.Context, arg db.UpdateVerifyEmailParams) (db.VerifyEmail, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateVerifyEmail", ctx, arg)
|
||||
ret0, _ := ret[0].(db.VerifyEmail)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateVerifyEmail indicates an expected call of UpdateVerifyEmail.
|
||||
func (mr *MockStoreMockRecorder) UpdateVerifyEmail(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateVerifyEmail", reflect.TypeOf((*MockStore)(nil).UpdateVerifyEmail), ctx, arg)
|
||||
}
|
||||
|
||||
// VerifyEmailTx mocks base method.
|
||||
func (m *MockStore) VerifyEmailTx(ctx context.Context, arg db.VerifyEmailTxParams) (db.VerifyEmailTxResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "VerifyEmailTx", ctx, arg)
|
||||
ret0, _ := ret[0].(db.VerifyEmailTxResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// VerifyEmailTx indicates an expected call of VerifyEmailTx.
|
||||
func (mr *MockStoreMockRecorder) VerifyEmailTx(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyEmailTx", reflect.TypeOf((*MockStore)(nil).VerifyEmailTx), ctx, arg)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const (
|
||||
ForeignKeyViolation = "23503"
|
||||
UniqueViolation = "23505"
|
||||
CheckViolation = "23514"
|
||||
)
|
||||
|
||||
var ErrRecordNotFound = pgx.ErrNoRows
|
||||
|
||||
var ErrUniqueViolation = &pgconn.PgError{
|
||||
Code: UniqueViolation,
|
||||
}
|
||||
var ErrForeignKeyViolation = &pgconn.PgError{
|
||||
Code: ForeignKeyViolation,
|
||||
}
|
||||
var ErrCheckViolation = &pgconn.PgError{
|
||||
Code: CheckViolation,
|
||||
}
|
||||
|
||||
func ErrorCode(err error) string {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// execTx executes a function within a database transaction
|
||||
func (store *PostgresStore) execTx(ctx context.Context, fn func(*Queries) error) error {
|
||||
tx, err := store.connPool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := New(tx)
|
||||
|
||||
err = fn(q)
|
||||
if err != nil {
|
||||
if rbErr := tx.Rollback(ctx); rbErr != nil {
|
||||
return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
var testStore Store
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
config, err := config.LoadTestConfig("../../..")
|
||||
if err != nil {
|
||||
log.Fatal("cannot load config:", err)
|
||||
}
|
||||
|
||||
connPool, err := pgxpool.New(context.Background(), config.DBAddr)
|
||||
if err != nil {
|
||||
log.Fatal("cannot connect to db:", err)
|
||||
}
|
||||
|
||||
testStore = NewPostgresStore(connPool)
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ClientIp string `json:"client_ip"`
|
||||
IsBlocked bool `json:"is_blocked"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
IsEmailVerified bool `json:"is_email_verified"`
|
||||
PasswordChangedAt time.Time `json:"password_changed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type VerifyEmail struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
SecretCode string `json:"secret_code"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiredAt time.Time `json:"expired_at"`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
CreateVerifyEmail(ctx context.Context, arg CreateVerifyEmailParams) (VerifyEmail, error)
|
||||
GetSession(ctx context.Context, id uuid.UUID) (Session, error)
|
||||
GetUser(ctx context.Context, username string) (User, error)
|
||||
UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error)
|
||||
UpdateVerifyEmail(ctx context.Context, arg UpdateVerifyEmailParams) (VerifyEmail, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -0,0 +1,83 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: sessions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
username,
|
||||
refresh_token,
|
||||
user_agent,
|
||||
client_ip,
|
||||
is_blocked,
|
||||
expires_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7
|
||||
)
|
||||
RETURNING id, username, refresh_token, user_agent, client_ip, is_blocked, expires_at, created_at
|
||||
`
|
||||
|
||||
type CreateSessionParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ClientIp string `json:"client_ip"`
|
||||
IsBlocked bool `json:"is_blocked"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, createSession,
|
||||
arg.ID,
|
||||
arg.Username,
|
||||
arg.RefreshToken,
|
||||
arg.UserAgent,
|
||||
arg.ClientIp,
|
||||
arg.IsBlocked,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.RefreshToken,
|
||||
&i.UserAgent,
|
||||
&i.ClientIp,
|
||||
&i.IsBlocked,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSession = `-- name: GetSession :one
|
||||
SELECT id, username, refresh_token, user_agent, client_ip, is_blocked, expires_at, created_at FROM sessions
|
||||
WHERE id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSession(ctx context.Context, id uuid.UUID) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, getSession, id)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.RefreshToken,
|
||||
&i.UserAgent,
|
||||
&i.ClientIp,
|
||||
&i.IsBlocked,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Querier
|
||||
CreateUserTx(ctx context.Context, arg CreateUserTxParams) (CreateUserTxResult, error)
|
||||
VerifyEmailTx(ctx context.Context, arg VerifyEmailTxParams) (VerifyEmailTxResult, error)
|
||||
}
|
||||
|
||||
// PostgresStore provides all functions to execute db queries and transactions
|
||||
type PostgresStore struct {
|
||||
*Queries
|
||||
connPool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresStore creates a new Store
|
||||
func NewPostgresStore(connPool *pgxpool.Pool) Store {
|
||||
return &PostgresStore{
|
||||
connPool: connPool,
|
||||
Queries: New(connPool),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type CreateUserTxParams struct {
|
||||
CreateUserParams
|
||||
AfterCreate func(user User) error
|
||||
}
|
||||
|
||||
type CreateUserTxResult struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func (store *PostgresStore) CreateUserTx(ctx context.Context, arg CreateUserTxParams) (CreateUserTxResult, error) {
|
||||
var result CreateUserTxResult
|
||||
|
||||
err := store.execTx(ctx, func(q *Queries) error {
|
||||
var err error
|
||||
|
||||
result.User, err = q.CreateUser(ctx, arg.CreateUserParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Msgf("user %s created successfully", result.User.Username)
|
||||
|
||||
return arg.AfterCreate(result.User)
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type VerifyEmailTxParams struct {
|
||||
EmailID int64
|
||||
SecretCode string
|
||||
}
|
||||
|
||||
type VerifyEmailTxResult struct {
|
||||
User User
|
||||
VerifyEmail VerifyEmail
|
||||
}
|
||||
|
||||
func (store *PostgresStore) VerifyEmailTx(ctx context.Context, arg VerifyEmailTxParams) (VerifyEmailTxResult, error) {
|
||||
var result VerifyEmailTxResult
|
||||
|
||||
err := store.execTx(ctx, func(q *Queries) error {
|
||||
var err error
|
||||
|
||||
result.VerifyEmail, err = q.UpdateVerifyEmail(ctx, UpdateVerifyEmailParams{
|
||||
ID: arg.EmailID,
|
||||
SecretCode: arg.SecretCode,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.User, err = q.UpdateUser(ctx, UpdateUserParams{
|
||||
Username: result.VerifyEmail.Username,
|
||||
IsEmailVerified: pgtype.Bool{
|
||||
Bool: true,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: user.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (
|
||||
username,
|
||||
hashed_password,
|
||||
full_name,
|
||||
email
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)
|
||||
RETURNING username, role, hashed_password, full_name, email, is_email_verified, password_changed_at, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Username string `json:"username"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, createUser,
|
||||
arg.Username,
|
||||
arg.HashedPassword,
|
||||
arg.FullName,
|
||||
arg.Email,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.Role,
|
||||
&i.HashedPassword,
|
||||
&i.FullName,
|
||||
&i.Email,
|
||||
&i.IsEmailVerified,
|
||||
&i.PasswordChangedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT username, role, hashed_password, full_name, email, is_email_verified, password_changed_at, created_at FROM users
|
||||
WHERE username = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, username string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUser, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.Role,
|
||||
&i.HashedPassword,
|
||||
&i.FullName,
|
||||
&i.Email,
|
||||
&i.IsEmailVerified,
|
||||
&i.PasswordChangedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUser = `-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET
|
||||
hashed_password = COALESCE($1, hashed_password),
|
||||
password_changed_at = COALESCE($2, password_changed_at),
|
||||
full_name = COALESCE($3, full_name),
|
||||
email = COALESCE($4, email),
|
||||
is_email_verified = COALESCE($5, is_email_verified)
|
||||
WHERE username = $6
|
||||
RETURNING username, role, hashed_password, full_name, email, is_email_verified, password_changed_at, created_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
HashedPassword pgtype.Text `json:"hashed_password"`
|
||||
PasswordChangedAt pgtype.Timestamptz `json:"password_changed_at"`
|
||||
FullName pgtype.Text `json:"full_name"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
IsEmailVerified pgtype.Bool `json:"is_email_verified"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, updateUser,
|
||||
arg.HashedPassword,
|
||||
arg.PasswordChangedAt,
|
||||
arg.FullName,
|
||||
arg.Email,
|
||||
arg.IsEmailVerified,
|
||||
arg.Username,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.Username,
|
||||
&i.Role,
|
||||
&i.HashedPassword,
|
||||
&i.FullName,
|
||||
&i.Email,
|
||||
&i.IsEmailVerified,
|
||||
&i.PasswordChangedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/security"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createRandomUser(t *testing.T) User {
|
||||
hashedPassword, err := security.HashPassword(testutil.RandomString(6))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create the user
|
||||
arg := CreateUserParams{
|
||||
Username: testutil.RandomOwner(),
|
||||
HashedPassword: hashedPassword,
|
||||
FullName: testutil.RandomOwner(),
|
||||
Email: testutil.RandomEmail(),
|
||||
}
|
||||
user, err := testStore.CreateUser(context.Background(), arg)
|
||||
require.NoError(t, err) // -> Err should be nil
|
||||
require.NotEmpty(t, user)
|
||||
|
||||
require.Equal(t, arg.Username, user.Username)
|
||||
require.Equal(t, arg.HashedPassword, user.HashedPassword)
|
||||
require.Equal(t, arg.FullName, user.FullName)
|
||||
require.Equal(t, arg.Email, user.Email)
|
||||
|
||||
require.True(t, user.PasswordChangedAt.IsZero())
|
||||
require.NotZero(t, user.CreatedAt)
|
||||
return user
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
createRandomUser(t)
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
user1 := createRandomUser(t)
|
||||
user2, err := testStore.GetUser(context.Background(), user1.Username)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, user2)
|
||||
|
||||
require.Equal(t, user1.Username, user2.Username)
|
||||
require.Equal(t, user1.HashedPassword, user2.HashedPassword)
|
||||
require.Equal(t, user1.FullName, user2.FullName)
|
||||
require.Equal(t, user1.Email, user2.Email)
|
||||
require.WithinDuration(t, user1.CreatedAt, user2.CreatedAt, time.Second)
|
||||
}
|
||||
|
||||
func TestUpdateUserOnlyFullName(t *testing.T) {
|
||||
oldUser := createRandomUser(t)
|
||||
|
||||
newFullName := testutil.RandomOwner()
|
||||
updatedUser, err := testStore.UpdateUser(context.Background(), UpdateUserParams{
|
||||
Username: oldUser.Username,
|
||||
FullName: pgtype.Text{
|
||||
String: newFullName,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, oldUser.FullName, updatedUser.FullName)
|
||||
require.Equal(t, newFullName, updatedUser.FullName)
|
||||
require.Equal(t, oldUser.HashedPassword, updatedUser.HashedPassword)
|
||||
require.Equal(t, oldUser.Email, updatedUser.Email)
|
||||
}
|
||||
|
||||
func TestUpdateUserOnlyEmail(t *testing.T) {
|
||||
oldUser := createRandomUser(t)
|
||||
|
||||
newEmail := testutil.RandomEmail()
|
||||
updatedUser, err := testStore.UpdateUser(context.Background(), UpdateUserParams{
|
||||
Username: oldUser.Username,
|
||||
Email: pgtype.Text{
|
||||
String: newEmail,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, oldUser.Email, updatedUser.Email)
|
||||
require.Equal(t, newEmail, updatedUser.Email)
|
||||
require.Equal(t, oldUser.HashedPassword, updatedUser.HashedPassword)
|
||||
require.Equal(t, oldUser.FullName, updatedUser.FullName)
|
||||
}
|
||||
|
||||
func TestUpdateUserOnlyPassword(t *testing.T) {
|
||||
oldUser := createRandomUser(t)
|
||||
|
||||
newPassword, err := security.HashPassword(testutil.RandomString(6))
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedUser, err := testStore.UpdateUser(context.Background(), UpdateUserParams{
|
||||
Username: oldUser.Username,
|
||||
HashedPassword: pgtype.Text{
|
||||
String: newPassword,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, oldUser.HashedPassword, updatedUser.HashedPassword)
|
||||
require.Equal(t, newPassword, updatedUser.HashedPassword)
|
||||
require.Equal(t, oldUser.FullName, updatedUser.FullName)
|
||||
require.Equal(t, oldUser.Email, updatedUser.Email)
|
||||
}
|
||||
|
||||
func TestUpdateUserAllFields(t *testing.T) {
|
||||
oldUser := createRandomUser(t)
|
||||
|
||||
newFullName := testutil.RandomOwner()
|
||||
newEmail := testutil.RandomEmail()
|
||||
newPassword, err := security.HashPassword(testutil.RandomString(6))
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedUser, err := testStore.UpdateUser(context.Background(), UpdateUserParams{
|
||||
Username: oldUser.Username,
|
||||
FullName: pgtype.Text{
|
||||
String: newFullName,
|
||||
Valid: true,
|
||||
},
|
||||
Email: pgtype.Text{
|
||||
String: newEmail,
|
||||
Valid: true,
|
||||
},
|
||||
HashedPassword: pgtype.Text{
|
||||
String: newPassword,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, oldUser.FullName, updatedUser.FullName)
|
||||
require.Equal(t, newFullName, updatedUser.FullName)
|
||||
|
||||
require.NotEqual(t, oldUser.HashedPassword, updatedUser.HashedPassword)
|
||||
require.Equal(t, newPassword, updatedUser.HashedPassword)
|
||||
|
||||
require.NotEqual(t, oldUser.Email, updatedUser.Email)
|
||||
require.Equal(t, newEmail, updatedUser.Email)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: verify_email.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createVerifyEmail = `-- name: CreateVerifyEmail :one
|
||||
INSERT INTO verify_emails (
|
||||
username,
|
||||
email,
|
||||
secret_code
|
||||
) VALUES (
|
||||
$1, $2, $3
|
||||
)
|
||||
RETURNING id, username, email, secret_code, is_used, created_at, expired_at
|
||||
`
|
||||
|
||||
type CreateVerifyEmailParams struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
SecretCode string `json:"secret_code"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateVerifyEmail(ctx context.Context, arg CreateVerifyEmailParams) (VerifyEmail, error) {
|
||||
row := q.db.QueryRow(ctx, createVerifyEmail, arg.Username, arg.Email, arg.SecretCode)
|
||||
var i VerifyEmail
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Email,
|
||||
&i.SecretCode,
|
||||
&i.IsUsed,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateVerifyEmail = `-- name: UpdateVerifyEmail :one
|
||||
UPDATE verify_emails
|
||||
SET
|
||||
is_used = TRUE
|
||||
WHERE
|
||||
id = $1
|
||||
AND secret_code = $2
|
||||
AND is_used = FALSE
|
||||
AND expired_at > NOW()
|
||||
RETURNING id, username, email, secret_code, is_used, created_at, expired_at
|
||||
`
|
||||
|
||||
type UpdateVerifyEmailParams struct {
|
||||
ID int64 `json:"id"`
|
||||
SecretCode string `json:"secret_code"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateVerifyEmail(ctx context.Context, arg UpdateVerifyEmailParams) (VerifyEmail, error) {
|
||||
row := q.db.QueryRow(ctx, updateVerifyEmail, arg.ID, arg.SecretCode)
|
||||
var i VerifyEmail
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Email,
|
||||
&i.SecretCode,
|
||||
&i.IsUsed,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiredAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package role
|
||||
|
||||
import "slices"
|
||||
|
||||
const (
|
||||
MemberRole = "member"
|
||||
AdminRole = "admin"
|
||||
)
|
||||
|
||||
func HasPermission(role string, accessibleRoles []string) bool {
|
||||
return slices.Contains(accessibleRoles, role)
|
||||
}
|
||||
@@ -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
|
||||
// })
|
||||
@@ -0,0 +1,72 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"text/template"
|
||||
|
||||
"github.com/jordan-wright/email"
|
||||
)
|
||||
|
||||
const (
|
||||
smtpAuthAddress = "smtp.gmail.com"
|
||||
smtpServerAddress = "smtp.gmail.com:587"
|
||||
)
|
||||
|
||||
type GmailSender struct {
|
||||
name string
|
||||
fromEmailAddress string
|
||||
fromEmailPassword string
|
||||
}
|
||||
|
||||
func NewGmailSender(name, fromEmailAddress, fromEmailPassword string) MailSender {
|
||||
return &GmailSender{
|
||||
name: name,
|
||||
fromEmailAddress: fromEmailAddress,
|
||||
fromEmailPassword: fromEmailPassword,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GmailSender) Send(
|
||||
mailTemplate string,
|
||||
vars any,
|
||||
to []string,
|
||||
cc []string,
|
||||
bcc []string,
|
||||
attachFiles []string,
|
||||
) error {
|
||||
e := email.NewEmail()
|
||||
e.From = fmt.Sprintf("%s <%s>", s.name, s.fromEmailAddress)
|
||||
e.To = to
|
||||
e.Cc = cc
|
||||
e.Bcc = bcc
|
||||
|
||||
tmpl, err := template.ParseFS(FS, "templates/"+mailTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse email template: %w", err)
|
||||
}
|
||||
|
||||
subject := new(bytes.Buffer)
|
||||
if err := tmpl.ExecuteTemplate(subject, "subject", vars); err != nil {
|
||||
return fmt.Errorf("failed to execute subject template: %w", err)
|
||||
}
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
if err := tmpl.ExecuteTemplate(body, "body", vars); err != nil {
|
||||
return fmt.Errorf("failed to execute body template: %w", err)
|
||||
}
|
||||
|
||||
e.Subject = subject.String()
|
||||
e.HTML = body.Bytes()
|
||||
|
||||
for _, file := range attachFiles {
|
||||
_, err := e.AttachFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to attach file %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
smtpAuth := smtp.PlainAuth("", s.fromEmailAddress, s.fromEmailPassword, smtpAuthAddress)
|
||||
return e.Send(smtpServerAddress, smtpAuth)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package mailer provides functionalities for sending emails using predefined templates.
|
||||
package mailer
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed "templates"
|
||||
var FS embed.FS
|
||||
|
||||
const (
|
||||
UserWelcomeTemplate = "user_activation.tmpl"
|
||||
TestEmailTemplate = "test_email.tmpl"
|
||||
)
|
||||
|
||||
type MailSender interface {
|
||||
Send(
|
||||
mailTemplate string,
|
||||
vars any,
|
||||
to []string,
|
||||
cc []string,
|
||||
bcc []string,
|
||||
attachFiles []string,
|
||||
) error
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSendEmailWithGmail(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
config, err := config.LoadTestConfig("../..")
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := NewGmailSender(config.EmailSenderName, config.EmailSender, config.EmailPassword)
|
||||
|
||||
vars := struct {
|
||||
ServiceName string
|
||||
}{
|
||||
ServiceName: "Simple Bank",
|
||||
}
|
||||
|
||||
to := []string{"[email protected]"}
|
||||
|
||||
attatchFiles := []string{"../README.md"}
|
||||
|
||||
err = sender.Send(TestEmailTemplate, vars, to, nil, nil, attatchFiles)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "subject"}} Test Email From {{.ServiceName}} {{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello World!</h1>
|
||||
<p>This is a test email from {{.ServiceName}}.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "subject"}} Finish Registration with {{.ServiceName}} {{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body> <p>Hi {{.Username}},</p>
|
||||
<p>Thanks for signing up for {{.ServiceName}}. We're excited to have you on board!</p></p>
|
||||
<p>Before you can start using {{.ServiceName}}, you need to confirm your email address. Click the link below to confirm your email address:</p>
|
||||
<p><a href="{{.ActivationURL}}">{{.ActivationURL}}</a></p>
|
||||
<p>If you want to activate your account manually copy and paste the code from the link above</p>
|
||||
<p>If you didn't sign up for {{.ServiceName}}, you can safely ignore this email.</p>
|
||||
|
||||
<p>Thanks,</p>
|
||||
<p>The {{.ServiceName}} Team</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
func CheckPassword(password, hashedPassword string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPassword(t *testing.T) {
|
||||
password := testutil.RandomString(6)
|
||||
|
||||
hashedPassword, err := HashPassword(password)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hashedPassword)
|
||||
|
||||
err = CheckPassword(password, hashedPassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
wrongPassword := testutil.RandomString(6)
|
||||
err = CheckPassword(wrongPassword, hashedPassword)
|
||||
require.Error(t, err)
|
||||
|
||||
hashedPassword2, err := HashPassword(password)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hashedPassword2)
|
||||
|
||||
require.NotEqual(t, hashedPassword, hashedPassword2)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/security"
|
||||
)
|
||||
|
||||
type authService struct {
|
||||
store db.Store
|
||||
tokenMaker auth.Maker
|
||||
config config.Config
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(store db.Store, tokenMaker auth.Maker, config config.Config) AuthService {
|
||||
return &authService{
|
||||
store: store,
|
||||
tokenMaker: tokenMaker,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authService) Login(ctx context.Context, params LoginCredentials) (*LoginResponse, error) {
|
||||
// Get user from database
|
||||
user, err := s.store.GetUser(ctx, params.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
// Verify password
|
||||
err = security.CheckPassword(params.Password, user.HashedPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("incorrect username or password: %w", err)
|
||||
}
|
||||
|
||||
// Create access token
|
||||
accessToken, accessPayload, err := s.tokenMaker.CreateToken(
|
||||
user.Username,
|
||||
user.Role,
|
||||
s.config.AuthTokenExp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create access token: %w", err)
|
||||
}
|
||||
|
||||
// Create refresh token
|
||||
refreshToken, refreshPayload, err := s.tokenMaker.CreateToken(
|
||||
user.Username,
|
||||
user.Role,
|
||||
s.config.RefreshTokenExp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Create session
|
||||
session, err := s.store.CreateSession(ctx, db.CreateSessionParams{
|
||||
ID: refreshPayload.ID,
|
||||
Username: user.Username,
|
||||
RefreshToken: refreshToken,
|
||||
UserAgent: params.UserAgent,
|
||||
ClientIp: params.ClientIP,
|
||||
IsBlocked: false,
|
||||
ExpiresAt: refreshPayload.ExpiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
SessionID: session.ID,
|
||||
AccessToken: accessToken,
|
||||
AccessTokenExpiresAt: accessPayload.ExpiredAt,
|
||||
RefreshToken: refreshToken,
|
||||
RefreshTokenExpiresAt: refreshPayload.ExpiredAt,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authService) RenewAccessToken(ctx context.Context, params TokenRenewalRequest) (*TokenRenewalResponse, error) {
|
||||
// Verify refresh token
|
||||
refreshPayload, err := s.tokenMaker.VerifyToken(params.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Get session
|
||||
session, err := s.store.GetSession(ctx, refreshPayload.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("session not found: %w", err)
|
||||
}
|
||||
|
||||
// Validate session
|
||||
if err := s.validateSession(session, refreshPayload.Username, params.RefreshToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new access token
|
||||
accessToken, accessPayload, err := s.tokenMaker.CreateToken(
|
||||
refreshPayload.Username,
|
||||
refreshPayload.Role,
|
||||
s.config.AuthTokenExp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create access token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenRenewalResponse{
|
||||
AccessToken: accessToken,
|
||||
AccessTokenExpiresAt: accessPayload.ExpiredAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateSession contains business rules for session validation
|
||||
func (s *authService) validateSession(session db.Session, username, refreshToken string) error {
|
||||
if session.IsBlocked {
|
||||
return fmt.Errorf("session is blocked")
|
||||
}
|
||||
|
||||
if session.Username != username {
|
||||
return fmt.Errorf("incorrect session user")
|
||||
}
|
||||
|
||||
if session.RefreshToken != refreshToken {
|
||||
return fmt.Errorf("mismatched session token")
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
return fmt.Errorf("session has expired")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/FernandoJVideira/simple_bank/internal/service (interfaces: UserService,AuthService)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen --package mocksvc --destination internal/service/mock/service.go github.com/FernandoJVideira/simple_bank/internal/service UserService,AuthService
|
||||
//
|
||||
|
||||
// Package mocksvc is a generated GoMock package.
|
||||
package mocksvc
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
service "github.com/FernandoJVideira/simple_bank/internal/service"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockUserService is a mock of UserService interface.
|
||||
type MockUserService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockUserServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockUserServiceMockRecorder is the mock recorder for MockUserService.
|
||||
type MockUserServiceMockRecorder struct {
|
||||
mock *MockUserService
|
||||
}
|
||||
|
||||
// NewMockUserService creates a new mock instance.
|
||||
func NewMockUserService(ctrl *gomock.Controller) *MockUserService {
|
||||
mock := &MockUserService{ctrl: ctrl}
|
||||
mock.recorder = &MockUserServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockUserService) EXPECT() *MockUserServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// CreateUser mocks base method.
|
||||
func (m *MockUserService) CreateUser(ctx context.Context, params service.RegisterUserRequest) (*service.RegisterUserResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateUser", ctx, params)
|
||||
ret0, _ := ret[0].(*service.RegisterUserResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateUser indicates an expected call of CreateUser.
|
||||
func (mr *MockUserServiceMockRecorder) CreateUser(ctx, params any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockUserService)(nil).CreateUser), ctx, params)
|
||||
}
|
||||
|
||||
// GetUser mocks base method.
|
||||
func (m *MockUserService) GetUser(ctx context.Context, username string) (*db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUser", ctx, username)
|
||||
ret0, _ := ret[0].(*db.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUser indicates an expected call of GetUser.
|
||||
func (mr *MockUserServiceMockRecorder) GetUser(ctx, username any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockUserService)(nil).GetUser), ctx, username)
|
||||
}
|
||||
|
||||
// UpdatePassword mocks base method.
|
||||
func (m *MockUserService) UpdatePassword(ctx context.Context, params service.PasswordUpdateRequest) (*db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdatePassword", ctx, params)
|
||||
ret0, _ := ret[0].(*db.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdatePassword indicates an expected call of UpdatePassword.
|
||||
func (mr *MockUserServiceMockRecorder) UpdatePassword(ctx, params any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePassword", reflect.TypeOf((*MockUserService)(nil).UpdatePassword), ctx, params)
|
||||
}
|
||||
|
||||
// UpdateUser mocks base method.
|
||||
func (m *MockUserService) UpdateUser(ctx context.Context, params service.UserUpdateRequest) (*db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUser", ctx, params)
|
||||
ret0, _ := ret[0].(*db.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUser indicates an expected call of UpdateUser.
|
||||
func (mr *MockUserServiceMockRecorder) UpdateUser(ctx, params any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockUserService)(nil).UpdateUser), ctx, params)
|
||||
}
|
||||
|
||||
// VerifyEmail mocks base method.
|
||||
func (m *MockUserService) VerifyEmail(ctx context.Context, params service.EmailVerificationRequest) (*service.EmailVerificationResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "VerifyEmail", ctx, params)
|
||||
ret0, _ := ret[0].(*service.EmailVerificationResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// VerifyEmail indicates an expected call of VerifyEmail.
|
||||
func (mr *MockUserServiceMockRecorder) VerifyEmail(ctx, params any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyEmail", reflect.TypeOf((*MockUserService)(nil).VerifyEmail), ctx, params)
|
||||
}
|
||||
|
||||
// MockAuthService is a mock of AuthService interface.
|
||||
type MockAuthService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockAuthServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockAuthServiceMockRecorder is the mock recorder for MockAuthService.
|
||||
type MockAuthServiceMockRecorder struct {
|
||||
mock *MockAuthService
|
||||
}
|
||||
|
||||
// NewMockAuthService creates a new mock instance.
|
||||
func NewMockAuthService(ctrl *gomock.Controller) *MockAuthService {
|
||||
mock := &MockAuthService{ctrl: ctrl}
|
||||
mock.recorder = &MockAuthServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockAuthService) EXPECT() *MockAuthServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Login mocks base method.
|
||||
func (m *MockAuthService) Login(ctx context.Context, params service.LoginCredentials) (*service.LoginResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Login", ctx, params)
|
||||
ret0, _ := ret[0].(*service.LoginResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Login indicates an expected call of Login.
|
||||
func (mr *MockAuthServiceMockRecorder) Login(ctx, params any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Login", reflect.TypeOf((*MockAuthService)(nil).Login), ctx, params)
|
||||
}
|
||||
|
||||
// RenewAccessToken mocks base method.
|
||||
func (m *MockAuthService) RenewAccessToken(ctx context.Context, params service.TokenRenewalRequest) (*service.TokenRenewalResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RenewAccessToken", ctx, params)
|
||||
ret0, _ := ret[0].(*service.TokenRenewalResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// RenewAccessToken indicates an expected call of RenewAccessToken.
|
||||
func (mr *MockAuthServiceMockRecorder) RenewAccessToken(ctx, params any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewAccessToken", reflect.TypeOf((*MockAuthService)(nil).RenewAccessToken), ctx, params)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
CreateUser(ctx context.Context, params RegisterUserRequest) (*RegisterUserResponse, error)
|
||||
GetUser(ctx context.Context, username string) (*db.User, error)
|
||||
UpdateUser(ctx context.Context, params UserUpdateRequest) (*db.User, error)
|
||||
UpdatePassword(ctx context.Context, params PasswordUpdateRequest) (*db.User, error)
|
||||
VerifyEmail(ctx context.Context, params EmailVerificationRequest) (*EmailVerificationResponse, error)
|
||||
}
|
||||
|
||||
// AuthService defines operations related to authentication
|
||||
type AuthService interface {
|
||||
Login(ctx context.Context, params LoginCredentials) (*LoginResponse, error)
|
||||
RenewAccessToken(ctx context.Context, params TokenRenewalRequest) (*TokenRenewalResponse, error)
|
||||
}
|
||||
|
||||
// Ensure implementations satisfy interfaces at compile time
|
||||
var (
|
||||
_ UserService = (*userService)(nil)
|
||||
_ AuthService = (*authService)(nil)
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// User Service Types - Use business operation names
|
||||
|
||||
type RegisterUserRequest struct {
|
||||
Username string
|
||||
Password string
|
||||
FullName string
|
||||
Email string
|
||||
}
|
||||
|
||||
type RegisterUserResponse struct {
|
||||
User db.User
|
||||
}
|
||||
|
||||
type UserUpdateRequest struct {
|
||||
Username string
|
||||
FullName *string
|
||||
Email *string
|
||||
}
|
||||
|
||||
type PasswordUpdateRequest struct {
|
||||
Username string
|
||||
OldPassword string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type EmailVerificationRequest struct {
|
||||
EmailID int64
|
||||
SecretCode string
|
||||
}
|
||||
|
||||
type EmailVerificationResponse struct {
|
||||
User db.User
|
||||
}
|
||||
|
||||
// Auth Service Types - Clear, semantic names
|
||||
|
||||
type LoginCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
UserAgent string
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
SessionID uuid.UUID
|
||||
AccessToken string
|
||||
AccessTokenExpiresAt time.Time
|
||||
RefreshToken string
|
||||
RefreshTokenExpiresAt time.Time
|
||||
User db.User
|
||||
}
|
||||
|
||||
type TokenRenewalRequest struct {
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
type TokenRenewalResponse struct {
|
||||
AccessToken string
|
||||
AccessTokenExpiresAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/security"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/worker"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type userService struct {
|
||||
store db.Store
|
||||
taskDistributor worker.TaskDistributor
|
||||
}
|
||||
|
||||
// NewUserService creates a new user service
|
||||
func NewUserService(store db.Store, taskDistributor worker.TaskDistributor) UserService {
|
||||
return &userService{
|
||||
store: store,
|
||||
taskDistributor: taskDistributor,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *userService) CreateUser(ctx context.Context, params RegisterUserRequest) (*RegisterUserResponse, error) {
|
||||
// Hash the password
|
||||
hashedPassword, err := security.HashPassword(params.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Prepare transaction parameters
|
||||
arg := db.CreateUserTxParams{
|
||||
CreateUserParams: db.CreateUserParams{
|
||||
Username: params.Username,
|
||||
FullName: params.FullName,
|
||||
Email: params.Email,
|
||||
HashedPassword: hashedPassword,
|
||||
},
|
||||
AfterCreate: func(user db.User) error {
|
||||
opts := []asynq.Option{
|
||||
asynq.MaxRetry(10),
|
||||
asynq.ProcessIn(10 * time.Second),
|
||||
asynq.Queue(worker.CriticalQueue),
|
||||
}
|
||||
taskPayload := &worker.SendVerifyEmailPayload{
|
||||
Username: user.Username,
|
||||
}
|
||||
return s.taskDistributor.DestributeTaskSendVerifyEmail(ctx, taskPayload, opts...)
|
||||
},
|
||||
}
|
||||
|
||||
// Execute transaction
|
||||
txResult, err := s.store.CreateUserTx(ctx, arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
return &RegisterUserResponse{User: txResult.User}, nil
|
||||
}
|
||||
|
||||
func (s *userService) GetUser(ctx context.Context, username string) (*db.User, error) {
|
||||
user, err := s.store.GetUser(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *userService) UpdateUser(ctx context.Context, params UserUpdateRequest) (*db.User, error) {
|
||||
arg := db.UpdateUserParams{
|
||||
Username: params.Username,
|
||||
FullName: pgtype.Text{
|
||||
String: *params.FullName,
|
||||
Valid: params.FullName != nil,
|
||||
},
|
||||
Email: pgtype.Text{
|
||||
String: *params.Email,
|
||||
Valid: params.Email != nil,
|
||||
},
|
||||
}
|
||||
|
||||
user, err := s.store.UpdateUser(ctx, arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *userService) UpdatePassword(ctx context.Context, params PasswordUpdateRequest) (*db.User, error) {
|
||||
user, err := s.store.GetUser(ctx, params.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = security.CheckPassword(params.OldPassword, user.HashedPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("old password does not match: %w", err)
|
||||
}
|
||||
|
||||
newHashedPassword, err := security.HashPassword(params.NewPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
updUser, err := s.store.UpdateUser(ctx, db.UpdateUserParams{
|
||||
Username: params.Username,
|
||||
HashedPassword: pgtype.Text{
|
||||
String: newHashedPassword,
|
||||
Valid: true,
|
||||
},
|
||||
PasswordChangedAt: pgtype.Timestamptz{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updUser, nil
|
||||
}
|
||||
|
||||
func (s *userService) VerifyEmail(ctx context.Context, params EmailVerificationRequest) (*EmailVerificationResponse, error) {
|
||||
txRes, err := s.store.VerifyEmailTx(ctx, db.VerifyEmailTxParams{
|
||||
EmailID: params.EmailID,
|
||||
SecretCode: params.SecretCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &EmailVerificationResponse{User: txRes.User}, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package val
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
isValidUsername = regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString
|
||||
isValidFullName = regexp.MustCompile(`^[a-zA-Z\s]+$`).MatchString
|
||||
)
|
||||
|
||||
func ValidateString(val string, minLength int, maxLength int) error {
|
||||
n := len(val)
|
||||
if n < minLength || n > maxLength {
|
||||
return fmt.Errorf("string length must be between %d and %d characters", minLength, maxLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateUsername(val string) error {
|
||||
err := ValidateString(val, 3, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid username: %v", err)
|
||||
}
|
||||
|
||||
if !isValidUsername(val) {
|
||||
return fmt.Errorf("username can only contain lowercase alphanumeric characters and underscores")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateFullName(val string) error {
|
||||
err := ValidateString(val, 3, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid full name: %v", err)
|
||||
}
|
||||
|
||||
if !isValidFullName(val) {
|
||||
return fmt.Errorf("full name can only contain alphabetic characters and spaces")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePassword(val string) error {
|
||||
return ValidateString(val, 6, 100)
|
||||
}
|
||||
|
||||
func ValidateEmail(val string) error {
|
||||
err := ValidateString(val, 3, 200)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid username: %v", err)
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(val); err != nil {
|
||||
return fmt.Errorf("is not a valid email address")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateEmailID(val int64) error {
|
||||
if val <= 0 {
|
||||
return fmt.Errorf("email ID must be a positive integer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateVerificationCode(val string) error {
|
||||
return ValidateString(val, 32, 128)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
type TaskDistributor interface {
|
||||
DestributeTaskSendVerifyEmail(ctx context.Context, payload *SendVerifyEmailPayload, opts ...asynq.Option) error
|
||||
}
|
||||
|
||||
type RedisTaskDistributor struct {
|
||||
client *asynq.Client
|
||||
}
|
||||
|
||||
func NewRedisTaskDistributor(redisOpt asynq.RedisClientOpt) TaskDistributor {
|
||||
client := asynq.NewClient(redisOpt)
|
||||
return &RedisTaskDistributor{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Logger struct{}
|
||||
|
||||
func NewLogger() *Logger {
|
||||
return &Logger{}
|
||||
}
|
||||
|
||||
func (l *Logger) Print(level zerolog.Level, args ...any) {
|
||||
log.WithLevel(level).Msg(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(ctx context.Context, format string, v ...any) {
|
||||
log.WithLevel(zerolog.DebugLevel).Msgf(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(args ...any) {
|
||||
l.Print(zerolog.DebugLevel, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) Info(args ...any) {
|
||||
l.Print(zerolog.InfoLevel, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) Warn(args ...any) {
|
||||
l.Print(zerolog.WarnLevel, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) Error(args ...any) {
|
||||
l.Print(zerolog.ErrorLevel, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) Fatal(args ...any) {
|
||||
l.Print(zerolog.FatalLevel, args...)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/FernandoJVideira/simple_bank/internal/worker (interfaces: TaskDistributor)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen --package mockwk --destination internal/worker/mock/distributor.go github.com/FernandoJVideira/simple_bank/internal/worker TaskDistributor
|
||||
//
|
||||
|
||||
// Package mockwk is a generated GoMock package.
|
||||
package mockwk
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
worker "github.com/FernandoJVideira/simple_bank/internal/worker"
|
||||
asynq "github.com/hibiken/asynq"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockTaskDistributor is a mock of TaskDistributor interface.
|
||||
type MockTaskDistributor struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockTaskDistributorMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockTaskDistributorMockRecorder is the mock recorder for MockTaskDistributor.
|
||||
type MockTaskDistributorMockRecorder struct {
|
||||
mock *MockTaskDistributor
|
||||
}
|
||||
|
||||
// NewMockTaskDistributor creates a new mock instance.
|
||||
func NewMockTaskDistributor(ctrl *gomock.Controller) *MockTaskDistributor {
|
||||
mock := &MockTaskDistributor{ctrl: ctrl}
|
||||
mock.recorder = &MockTaskDistributorMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockTaskDistributor) EXPECT() *MockTaskDistributorMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// DestributeTaskSendVerifyEmail mocks base method.
|
||||
func (m *MockTaskDistributor) DestributeTaskSendVerifyEmail(ctx context.Context, payload *worker.SendVerifyEmailPayload, opts ...asynq.Option) error {
|
||||
m.ctrl.T.Helper()
|
||||
varargs := []any{ctx, payload}
|
||||
for _, a := range opts {
|
||||
varargs = append(varargs, a)
|
||||
}
|
||||
ret := m.ctrl.Call(m, "DestributeTaskSendVerifyEmail", varargs...)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DestributeTaskSendVerifyEmail indicates an expected call of DestributeTaskSendVerifyEmail.
|
||||
func (mr *MockTaskDistributorMockRecorder) DestributeTaskSendVerifyEmail(ctx, payload any, opts ...any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
varargs := append([]any{ctx, payload}, opts...)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DestributeTaskSendVerifyEmail", reflect.TypeOf((*MockTaskDistributor)(nil).DestributeTaskSendVerifyEmail), varargs...)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/FernandoJVideira/simple_bank/internal/config"
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/mailer"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
CriticalQueue = "critical"
|
||||
DefaultQueue = "default"
|
||||
)
|
||||
|
||||
type TaskProcessor interface {
|
||||
Start() error
|
||||
Shutdown()
|
||||
ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error
|
||||
}
|
||||
|
||||
type RedisTaskProcessor struct {
|
||||
server *asynq.Server
|
||||
store db.Store
|
||||
mailer mailer.MailSender
|
||||
config config.Config
|
||||
}
|
||||
|
||||
func NewRedisTaskProcessor(redisOpt asynq.RedisClientOpt, store db.Store, mailer mailer.MailSender, config config.Config) TaskProcessor {
|
||||
logger := NewLogger()
|
||||
redis.SetLogger(logger)
|
||||
|
||||
server := asynq.NewServer(
|
||||
redisOpt,
|
||||
asynq.Config{
|
||||
Queues: map[string]int{
|
||||
CriticalQueue: 10,
|
||||
DefaultQueue: 5,
|
||||
},
|
||||
ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {
|
||||
log.Error().Err(err).Str("type", task.Type()).Bytes("payload", task.Payload()).Msg("process task failed")
|
||||
}),
|
||||
Logger: NewLogger(),
|
||||
},
|
||||
)
|
||||
return &RedisTaskProcessor{
|
||||
server: server,
|
||||
store: store,
|
||||
mailer: mailer,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RedisTaskProcessor) Start() error {
|
||||
mux := asynq.NewServeMux()
|
||||
|
||||
mux.HandleFunc(TaskSendVerifyEmail, r.ProcessTaskSendVerifyEmail)
|
||||
|
||||
return r.server.Start(mux)
|
||||
}
|
||||
|
||||
func (r *RedisTaskProcessor) Shutdown() {
|
||||
r.server.Shutdown()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
db "github.com/FernandoJVideira/simple_bank/internal/db/sqlc"
|
||||
"github.com/FernandoJVideira/simple_bank/internal/mailer"
|
||||
"github.com/FernandoJVideira/simple_bank/pkg/testutil"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const TaskSendVerifyEmail = "task:send_verify_email"
|
||||
|
||||
type SendVerifyEmailPayload struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (r *RedisTaskDistributor) DestributeTaskSendVerifyEmail(ctx context.Context, payload *SendVerifyEmailPayload, opts ...asynq.Option) error {
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal SendVerifyEmailPayload: %w", err)
|
||||
}
|
||||
|
||||
task := asynq.NewTask(TaskSendVerifyEmail, jsonPayload, opts...)
|
||||
info, err := r.client.EnqueueContext(ctx, task)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enqueue task %s: %w", TaskSendVerifyEmail, err)
|
||||
}
|
||||
|
||||
log.Info().Str("type", task.Type()).Bytes("payload", task.Payload()).Str("queue", info.Queue).Int("max_retries", info.MaxRetry).Msgf("enqueued task")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RedisTaskProcessor) ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error {
|
||||
var payload SendVerifyEmailPayload
|
||||
|
||||
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal payload: %w", asynq.SkipRetry)
|
||||
}
|
||||
|
||||
usr, err := r.store.GetUser(ctx, payload.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRecordNotFound) {
|
||||
return fmt.Errorf("user %s not found: %w", payload.Username, asynq.SkipRetry)
|
||||
}
|
||||
return fmt.Errorf("failed to get user %s: %w", payload.Username, err)
|
||||
}
|
||||
|
||||
verifyEmail, err := r.store.CreateVerifyEmail(ctx, db.CreateVerifyEmailParams{
|
||||
Username: usr.Username,
|
||||
Email: usr.Email,
|
||||
SecretCode: testutil.RandomString(32),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create verify email for user %s: %w", usr.Username, err)
|
||||
}
|
||||
|
||||
vars := struct {
|
||||
ServiceName string
|
||||
Username string
|
||||
ActivationURL string
|
||||
}{
|
||||
ServiceName: r.config.ServiceName,
|
||||
Username: usr.Username,
|
||||
ActivationURL: fmt.Sprintf("%s/verify_email?id=%d&code=%s", r.config.FrontendURL, verifyEmail.ID, verifyEmail.SecretCode),
|
||||
}
|
||||
|
||||
to := []string{usr.Email}
|
||||
|
||||
err = r.mailer.Send(mailer.UserWelcomeTemplate, vars, to, nil, nil, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send verify email to user %s: %w", usr.Username, err)
|
||||
}
|
||||
|
||||
log.Info().Str("type", task.Type()).Bytes("payload", task.Payload()).Str("email", usr.Email).Msg("sent verify email to user")
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user