feat: 0.0.1

This commit is contained in:
FernandoJVideira committed 2026-01-08 01:15:07 +00:00
commit 961bdd81d4
146 files changed
+10444

No files matched your search

+128
View File
@@ -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
}
+83
View File
@@ -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)
}
+11
View File
@@ -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)
}
+97
View File
@@ -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
}
+62
View File
@@ -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)
}
+39
View File
@@ -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
}
+65
View File
@@ -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
}
+178
View File
@@ -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)
}
+32
View File
@@ -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,
}
}
+34
View File
@@ -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 ""
}
+24
View File
@@ -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)
}
+30
View File
@@ -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())
}
+43
View File
@@ -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"`
}
+23
View File
@@ -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)
+83
View File
@@ -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
}
+27
View File
@@ -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),
}
}
+34
View File
@@ -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
}
+44
View File
@@ -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
}
+117
View File
@@ -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
}
+146
View File
@@ -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)
}
+74
View File
@@ -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
}
+12
View File
@@ -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)
}
+51
View File
@@ -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
}
+38
View File
@@ -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),
}
}
+24
View File
@@ -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()
}
+79
View File
@@ -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")
})
}
+46
View File
@@ -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)
}
+42
View File
@@ -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
}
+58
View File
@@ -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)
})
}
}
+64
View File
@@ -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
}
+88
View File
@@ -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
}
+43
View File
@@ -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
}
+38
View File
@@ -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())
}
+80
View File
@@ -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()
}
}
+114
View File
@@ -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)
})
}
}
+8
View File
@@ -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"`
}
+130
View File
@@ -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
}
+74
View File
@@ -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)
}
+429
View File
@@ -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)
}
+131
View File
@@ -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)
}
+13
View File
@@ -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
// })
+72
View File
@@ -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)
}
+23
View File
@@ -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
}
+32
View File
@@ -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)
}
+16
View File
@@ -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}}
+19
View File
@@ -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))
}
+29
View File
@@ -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)
}
+139
View File
@@ -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
}
+172
View File
@@ -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)
}
+27
View File
@@ -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)
)
+69
View File
@@ -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
}
+138
View File
@@ -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
}
+72
View File
@@ -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)
}
+22
View File
@@ -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,
}
}
+43
View File
@@ -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...)
}
+62
View File
@@ -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...)
}
+67
View File
@@ -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()
}
+81
View File
@@ -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
}