Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
jobs:
ci:
runs-on: ubuntu-latest
env:
TESTCONTAINER_DOCKER_NETWORK: nestled-testcontainers

steps:
- name: Checkout
Expand All @@ -29,6 +31,9 @@ jobs:
- name: Build
run: go build ./...

- name: Create Docker network for testcontainers
run: docker network create ${{ env.TESTCONTAINER_DOCKER_NETWORK }}

- name: Test
run: go test ./...

Expand Down
Binary file modified bin/nestled
Binary file not shown.
4 changes: 3 additions & 1 deletion cmd/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ func main() {

// services
userService := services.NewUserService(userRepository)
authService := services.NewAuthService(userRepository, configuration.JWT)

// Initialize handlers
userHandler := handlers.NewUserHandler(userService, logger)
healthHandler := handlers.NewHealthHandler(database)
authHandler := handlers.NewAuthHandler(authService)

routes.SetupRoutes(r, userHandler, healthHandler)
routes.SetupRoutes(r, userHandler, healthHandler, authHandler)

// Start the server
if err := r.Run(); err != nil {
Expand Down
5 changes: 4 additions & 1 deletion config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ database:
port: 5432
user: postgres
password: password
name: nestled_db
name: nestled_db
jwt:
secret: "your_secret_key"
expiration: 6
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-migrate/migrate v3.5.4+incompatible // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA=
github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
Expand Down
35 changes: 35 additions & 0 deletions internal/auth/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package auth

import (
"errors"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)

type Token struct {
Value string
ExpirationTime time.Time
}

func GenerateToken(userId uuid.UUID, secretKey string, expireHours int) (*Token, error) {
expirationTime := time.Now().Add(time.Hour * time.Duration(expireHours)).Local().UTC()

claims := jwt.MapClaims{
"userId": userId.String(),
"exp": expirationTime,
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(secretKey))

if err != nil {
return nil, errors.New("failed to generate token")
}

return &Token{
Value: signed,
ExpirationTime: expirationTime,
}, nil
}
21 changes: 21 additions & 0 deletions internal/auth/jwt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package auth

import (
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)

func TestGeneratingToken(t *testing.T) {
userId := uuid.New()
secretKey := "test_secret_key"
expireHours := 6

token, err := GenerateToken(userId, secretKey, expireHours)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assert.NotNil(t, token.Value, "expected token value to be set, got nil")

}
8 changes: 8 additions & 0 deletions internal/config/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ type DatabaseConfig struct {
Name string
}

type JWTConfig struct {
Secret string `mapstructure:"secret"`
Expiration int `mapstructure:"expiration"`
}

type Config struct {
Database DatabaseConfig
JWT JWTConfig
}

func LoadConfig() (*Config, error) {
Expand All @@ -31,6 +37,8 @@ func LoadConfig() (*Config, error) {
viper.BindEnv("database.user", "DB_USER")
viper.BindEnv("database.password", "DB_PASSWORD")
viper.BindEnv("database.name", "DB_NAME")
viper.BindEnv("jwt.secret", "JWT_SECRET_KEY")
viper.BindEnv("jwt.expiration", "JWT_EXPIRE_HOURS")

if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("fatal error config file: %w", err)
Expand Down
5 changes: 5 additions & 0 deletions internal/config/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ func TestLoadConfigSuccessfully(t *testing.T) {
assert.Equal(t, dbConfig.Password, "password")
assert.Equal(t, dbConfig.Name, "nestled_db")

jwtConfig := configuration.JWT

assert.Equal(t, "your_secret_key", jwtConfig.Secret)
assert.Equal(t, 6, jwtConfig.Expiration)

}
6 changes: 6 additions & 0 deletions internal/dto/login_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dto

type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
6 changes: 6 additions & 0 deletions internal/dto/token_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dto

type TokenResponse struct {
Token string `json:"token"`
ExpirationTime int64 `json:"expiration_time"`
}
1 change: 1 addition & 0 deletions internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ var (
ErrUsernameAlreadyExists = errors.New("username already exists")
ErrEmailAlreadyExists = errors.New("email already exists")
ErrPasswordTooWeak = errors.New("password is too weak")
CredentialsInvalid = errors.New("invalid credentials")
)
46 changes: 46 additions & 0 deletions internal/handlers/auth_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package handlers

import (
"errors"

"github.com/albertoadami/nestled/internal/dto"
appErrorrs "github.com/albertoadami/nestled/internal/errors"
"github.com/albertoadami/nestled/internal/services"
"github.com/gin-gonic/gin"
)

type AuthHandler struct {
authService services.AuthService
}

func NewAuthHandler(authService services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}

func (h *AuthHandler) GenerateToken(c *gin.Context) {
var request dto.LoginRequest

if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}

token, err := h.authService.GenerateToken(request.Username, request.Password)

switch {
case errors.Is(err, appErrorrs.CredentialsInvalid):
c.JSON(401, &dto.ErrorResponse{
Message: err.Error(),
Details: "Invalid username or password",
})
default:
tokenResponse := &dto.TokenResponse{
Token: token.Value,
ExpirationTime: token.ExpirationTime.UnixMilli(),
}
c.JSON(200, tokenResponse)
}

}
68 changes: 68 additions & 0 deletions internal/handlers/auth_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package handlers

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/albertoadami/nestled/internal/auth"
appErrorrs "github.com/albertoadami/nestled/internal/errors"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)

type mockAuthService struct {
generateTokenFn func(username string, password string) (*auth.Token, error)
}

func (m *mockAuthService) GenerateToken(username string, password string) (*auth.Token, error) {
return m.generateTokenFn(username, password)
}

func setupUserAuthRouter(mockService *mockAuthService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAuthHandler(mockService)
router.POST("/api/v1/auth/token", handler.GenerateToken)
return router
}

func TestGenerateTokenSueccessfully(t *testing.T) {

mockService := &mockAuthService{
generateTokenFn: func(username string, password string) (*auth.Token, error) {
return &auth.Token{Value: "mocked.jwt.token"}, nil
},
}
router := setupUserAuthRouter(mockService)

body := `{"username":"test","password":"secret123"}`
req, _ := http.NewRequest("POST", "/api/v1/auth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "mocked.jwt.token")
}

func TestGenerateTokenInvalidCredentials(t *testing.T) {
mockService := &mockAuthService{
generateTokenFn: func(username string, password string) (*auth.Token, error) {
return nil, appErrorrs.CredentialsInvalid
},
}
router := setupUserAuthRouter(mockService)

body := `{"username":"test","password":"wrong"}`
req, _ := http.NewRequest("POST", "/api/v1/auth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Invalid username or password")
}
14 changes: 7 additions & 7 deletions internal/model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ const (
)

type User struct {
Id uuid.UUID
Username string
FirstName string
LastName string
Email string
PasswordHash string
Status UserStatus
Id uuid.UUID `db:"id"`
Username string `db:"username"`
FirstName string `db:"first_name"`
LastName string `db:"last_name"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
Status UserStatus `db:"status"`
}
13 changes: 13 additions & 0 deletions internal/repositories/user_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

type UserRepository interface {
CreateUser(user *model.User) (uuid.UUID, error)
GetUserByUsername(username string) (*model.User, error)
}

type userRepository struct {
Expand Down Expand Up @@ -50,3 +51,15 @@ func (r *userRepository) CreateUser(user *model.User) (uuid.UUID, error) {

return id, nil
}

func (r *userRepository) GetUserByUsername(username string) (*model.User, error) {
query := `SELECT id, username, first_name, last_name, email, password_hash, status
FROM users
WHERE username = $1 AND status != 'BLOCKED'::user_status`
var user model.User
err := r.db.Get(&user, query, username)
if err != nil {
return nil, err
}
return &user, nil
}
41 changes: 41 additions & 0 deletions internal/repositories/user_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/albertoadami/nestled/internal/testhelpers"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
)

func createTestUser() *model.User {
Expand Down Expand Up @@ -104,3 +105,43 @@ func TestCreateUserFailedDueToDuplicateEmail(t *testing.T) {
t.Fatalf("expected error %v, got %v", apperrors.ErrEmailAlreadyExists, err)
}
}

func TestGetUserByUsernameSucessfully(t *testing.T) {

db, terminate := testhelpers.SetupPostgres(t)
defer terminate()
truncateUsers(t, db)

userRepo := NewUserRepository(db)
user := createTestUser()
_, err := userRepo.CreateUser(user)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

retrievedUser, err := userRepo.GetUserByUsername(user.Username)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

if retrievedUser.Username != user.Username {
t.Fatalf("expected username %v, got %v", user.Username, retrievedUser.Username)
}

}

func TestGetUserByUsernameFailedDueToNonExistingUser(t *testing.T) {

db, terminate := testhelpers.SetupPostgres(t)
defer terminate()
truncateUsers(t, db)

userRepo := NewUserRepository(db)

result, err := userRepo.GetUserByUsername("non-existing-username")
if err == nil {
t.Fatal("expected error, got nil")
}

assert.Nil(t, result, "expected result to be nil")
}
4 changes: 2 additions & 2 deletions internal/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import (

const ApiPrefix = "/api/v1"

func SetupRoutes(r *gin.Engine, userHandler *handlers.UserHandler, healthHandler *handlers.HealthHandler) {
func SetupRoutes(r *gin.Engine, userHandler *handlers.UserHandler, healthHandler *handlers.HealthHandler, authHandler *handlers.AuthHandler) {
r.GET("/health", healthHandler.Health)

apiGroup := r.Group(ApiPrefix)
apiGroup.POST("/register", userHandler.RegisterUser)

apiGroup.POST("/auth/token", authHandler.GenerateToken)
}
Loading