Compare commits

...

2 Commits

Author SHA1 Message Date
b572653d97 fix: implement user roles and authentication system with tests
All checks were successful
CI Pipeline / Build (push) Successful in 1m17s
CI Pipeline / Notification (push) Successful in 1s
CI Pipeline / Lint (push) Successful in 3m25s
CI Pipeline / Test (push) Successful in 4m6s
CI Pipeline / Security Scan (push) Successful in 6m13s
2025-06-05 20:00:50 +07:00
9a8c40eee2 fix: implement resource access middleware and router setup with auth integration
All checks were successful
CI Pipeline / Lint (push) Successful in 3m49s
CI Pipeline / Security Scan (push) Successful in 5m16s
CI Pipeline / Test (push) Successful in 2m21s
CI Pipeline / Build (push) Successful in 1m17s
CI Pipeline / Notification (push) Successful in 2s
2025-06-05 13:30:08 +07:00
26 changed files with 582 additions and 497 deletions

View File

@ -1,32 +0,0 @@
# App Configuration
APP_NAME="ULFlow Starter Kit"
APP_VERSION="0.1.0"
APP_ENVIRONMENT="development"
APP_TIMEZONE="Asia/Ho_Chi_Minh"
# Logger Configuration
LOG_LEVEL="info" # debug, info, warn, error
# Server Configuration
SERVER_HOST="0.0.0.0"
SERVER_PORT=3000
SERVER_READ_TIMEOUT=15
SERVER_WRITE_TIMEOUT=15
SERVER_SHUTDOWN_TIMEOUT=30
# Database Configuration
DB_DRIVER="postgres"
DB_HOST="localhost"
DB_PORT=5432
DB_USERNAME="postgres"
DB_PASSWORD="your_password_here"
DB_NAME="ulflow"
DB_SSLMODE="disable"
# JWT Configuration
JWT_SECRET="your-32-byte-base64-encoded-secret-key-here"
JWT_ACCESS_TOKEN_EXPIRE=15 # in minutes
JWT_REFRESH_TOKEN_EXPIRE=10080 # in minutes (7 days)
JWT_ALGORITHM="HS256"
JWT_ISSUER="ulflow-starter-kit"
JWT_AUDIENCE="ulflow-web"

1
.gitignore vendored
View File

@ -48,3 +48,4 @@ dist/
# OS specific files
.DS_Store
Thumbs.db
.obsidian

View File

@ -1,32 +0,0 @@
# Dockerfile.local
# Optimized for local development with hot reload
# Build stage
FROM golang:1.23-alpine AS builder
# Install necessary tools for development
RUN apk add --no-cache git make gcc libc-dev
# Install Air for hot reload
RUN go install github.com/cosmtrek/air@latest
# Set working directory
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum* ./
# Download dependencies
RUN go mod download
# Copy the entire project
COPY . .
# Expose port
EXPOSE 3000
# Set environment variable for development
ENV APP_ENV=development
# Command to run the application with hot reload
CMD ["air", "-c", ".air.toml"]

View File

@ -70,7 +70,7 @@ dev:
# Run all tests
test:
@echo "Running tests..."
go test -v -cover ./...
go test -cover ./...
# Run linters and code quality tools
lint:

View File

@ -97,6 +97,21 @@ sequenceDiagram
2. Kiểm tra user có vai trò phù hợp không
3. Trả về lỗi nếu không có quyền
#### `ResourceOwnerCheck(paramName string)`
- **Mục đích**: Ngăn chặn Horizontal Privilege Escalation bằng cách kiểm tra quyền sở hữu tài nguyên
- **Luồng xử lý**:
1. Lấy ID tài nguyên từ URL param
2. Lấy thông tin user từ context
3. Kiểm tra nếu user là chủ sở hữu (user ID = resource ID)
4. Hoặc kiểm tra nếu user có role admin
5. Từ chối truy cập nếu không thỏa điều kiện
#### `RequireOwnerOrRole(paramName string, roles ...string)`
- **Mục đích**: Kết hợp kiểm tra quyền sở hữu và vai trò
- **Luồng xử lý**:
1. Kiểm tra nếu user là chủ sở hữu tài nguyên
2. Hoặc kiểm tra nếu user có một trong các role được chỉ định
### Token Service
#### `GenerateTokens(userID, sessionID)`

View File

@ -15,80 +15,6 @@ Testing là một phần quan trọng trong quy trình phát triển, đảm b
- Test cases nên bao gồm cả happy path và error cases
- Coverage yêu cầu tối thiểu: 80%
- Sử dụng t.Run() để chạy các subtest
### Mẫu Unit Test
```go
// user_service_test.go
package user
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mocking repository
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) FindByID(id string) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func TestGetUser(t *testing.T) {
// Test cases
testCases := []struct {
name string
userID string
mockUser *User
mockError error
expectedUser *User
expectedError error
}{
{
name: "successful_get",
userID: "123",
mockUser: &User{ID: "123", Name: "Test User"},
mockError: nil,
expectedUser: &User{ID: "123", Name: "Test User"},
expectedError: nil,
},
{
name: "user_not_found",
userID: "456",
mockUser: nil,
mockError: ErrUserNotFound,
expectedUser: nil,
expectedError: ErrUserNotFound,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Setup mock
mockRepo := new(MockUserRepository)
mockRepo.On("FindByID", tc.userID).Return(tc.mockUser, tc.mockError)
// Create service with mock dependency
service := NewUserService(mockRepo)
// Call the method
user, err := service.GetUser(tc.userID)
// Assert results
assert.Equal(t, tc.expectedUser, user)
assert.Equal(t, tc.expectedError, err)
// Verify expectations
mockRepo.AssertExpectations(t)
})
}
}
```
## Integration Testing
### Approach

View File

@ -0,0 +1,63 @@
version: '3.8'
services:
# API Service
api:
build:
context: .
dockerfile: Dockerfile
container_name: ulflow-api-minimal
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./logs:/app/logs
- ./storage:/app/storage
env_file:
- .env
environment:
- DB_DRIVER=postgres
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=${DB_USERNAME:-postgres}
- DB_PASSWORD=${DB_PASSWORD:-your_password_here}
- DB_NAME=${DB_NAME:-ulflow}
depends_on:
- postgres
networks:
- ulflow-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: ulflow-postgres-minimal
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-your_password_here}
POSTGRES_DB: ${DB_NAME:-ulflow}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- ulflow-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-ulflow}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
postgres-data:
networks:
ulflow-network:
driver: bridge

View File

@ -1,11 +1,11 @@
version: '3.8'
services:
# API Service
# API ServiceƯ
api:
build:
context: .
dockerfile: Dockerfile.local
dockerfile: Dockerfile
container_name: ulflow-api
restart: unless-stopped
ports:
@ -50,7 +50,7 @@ services:
- GITEA__database__USER=${DB_USER:-user}
- GITEA__database__PASSWD=${DB_PASSWORD:-password}
ports:
- "3000:3000"
- "3001:3000"
- "2222:22"
volumes:
- gitea-data:/data

View File

@ -1,80 +0,0 @@
🚀 Cải Thiện Luồng Xác Thực Cho Project Starter-Kit
Một Starter-kit chất lượng cần có hệ thống xác thực được xây dựng trên các nguyên tắc bảo mật và thực hành tốt nhất. Dưới đây là những cải thiện quan trọng:
1. Bảo Mật Refresh Token (RT) Phía Client Ưu Tiên Hàng Đầu
Vấn đề cốt lõi: Lưu RT trong localStorage hoặc sessionStorage khiến chúng dễ bị tấn công XSS.
Giải pháp cho Starter-kit:
Sử dụng HttpOnly Cookies cho Refresh Token:
Bắt buộc: Starter-kit NÊN mặc định hoặc hướng dẫn rõ ràng việc sử dụng cookie HttpOnly để lưu RT. Điều này ngăn JavaScript phía client truy cập RT.
Access Token (AT) có thể được lưu trong bộ nhớ JavaScript (an toàn hơn localStorage cho AT có đời sống ngắn) hoặc sessionStorage nếu cần thiết cho SPA.
Thiết lập cờ Secure và SameSite cho Cookie:
Secure: Đảm bảo cookie chỉ được gửi qua HTTPS.
SameSite=Strict (hoặc SameSite=Lax): Giúp chống lại tấn công CSRF. Starter-kit NÊN có cấu hình này.
2. Quản Lý Refresh Token Phía Server Đảm Bảo An Toàn
Thực hành tốt đã có: Refresh Token Rotation (xoay vòng RT khi sử dụng) là rất tốt.
Cải thiện cho Starter-kit:
Vô hiệu hóa RT cũ NGAY LẬP TỨC khi xoay vòng: Đảm bảo RT đã sử dụng không còn giá trị.
Thu hồi RT khi Logout: Endpoint /api/v1/auth/logout PHẢI xóa hoặc đánh dấu RT là đã thu hồi trong cơ sở dữ liệu. Chỉ xóa ở client là không đủ.
(Khuyến nghị cho Starter-kit nâng cao): Cân nhắc cơ chế phát hiện việc sử dụng RT đã bị đánh cắp (ví dụ: nếu một RT cũ được dùng lại sau khi đã xoay vòng, hãy thu hồi tất cả RT của user đó).
3. Tăng Cường Quy Trình Đăng Ký Nền Tảng Người Dùng
Cải thiện cho Starter-kit:
Chính Sách Mật Khẩu Tối Thiểu:
Yêu cầu độ dài mật khẩu tối thiểu (ví dụ: 8 hoặc 10 ký tự). Starter-kit NÊN có điều này.
(Tùy chọn): Khuyến khích hoặc yêu cầu kết hợp chữ hoa, chữ thường, số, ký tự đặc biệt.
Xác Thực Email (Khuyến Nghị Mạnh Mẽ):
Starter-kit NÊN bao gồm module hoặc hướng dẫn tích hợp quy trình gửi email xác thực để kích hoạt tài khoản. Điều này giúp đảm bảo email hợp lệ và là kênh liên lạc quan trọng.
4. Bảo Vệ Chống Tấn Công Đăng Nhập Lớp Phòng Thủ Cơ Bản
Cải thiện cho Starter-kit:
Rate Limiting cho Endpoint Đăng Nhập: Áp dụng giới hạn số lần thử đăng nhập thất bại (/api/v1/auth/login) dựa trên IP hoặc username/email.
Thông Báo Lỗi Chung Chung: Tránh các thông báo lỗi tiết lộ thông tin (ví dụ: "Username không tồn tại" hoặc "Sai mật khẩu"). Thay vào đó, sử dụng thông báo chung như "Tên đăng nhập hoặc mật khẩu không chính xác."
5. Thực Hành Tốt Nhất với JWT Cốt Lõi Của Xác Thực
Cải thiện cho Starter-kit:
Quản Lý Secret Key An Toàn:
Hướng dẫn lưu trữ JWT secret key trong biến môi trường (environment variables).
Tuyệt đối KHÔNG hardcode secret key trong mã nguồn.
Sử Dụng Thuật Toán Ký Mạnh:
Mặc định sử dụng thuật toán đối xứng mạnh như HS256.
Khuyến nghị và cung cấp tùy chọn cho thuật toán bất đối xứng như RS256 (yêu cầu quản lý cặp public/private key) cho các hệ thống phức tạp hơn.
Giữ Payload của Access Token Nhỏ Gọn:
Chỉ chứa thông tin cần thiết nhất (ví dụ: userId, roles).
Cân nhắc thêm iss (issuer) và aud (audience) để tăng cường xác minh token.
6. Xử Lý Lỗi và Ghi Log (Logging) An Toàn
Cải thiện cho Starter-kit:
Không Ghi Log Thông Tin Nhạy Cảm: Tuyệt đối KHÔNG ghi log Access Token, Refresh Token, hoặc mật khẩu dưới bất kỳ hình thức nào.
Ghi Log Sự Kiện An Ninh: Hướng dẫn hoặc cung cấp cơ chế ghi log các sự kiện quan trọng (đăng nhập thành công/thất bại, yêu cầu làm mới token, thay đổi mật khẩu) một cách an toàn, không kèm dữ liệu nhạy cảm, để phục vụ việc giám sát và điều tra.
Bằng cách tích hợp những cải tiến này, Starter-kit của bạn sẽ cung cấp một điểm khởi đầu vững chắc và an toàn hơn cho các nhà phát triển.

View File

@ -10,10 +10,10 @@
- [x] Xác thực token với middleware
- [x] Phân quyền cơ bản
- [x] Database with GORM + Postgres
- [ ] Health Check
- [ ] Unit Test with testify (Template)
- [ ] CI/CD with Gitea for Dev Team
- [ ] Build and Deploy with Docker + Docker Compose on Local
- [x] Health Check
- [x] Unit Test with testify (Template)
- [x] CI/CD with Gitea for Dev Team
- [x] Build and Deploy with Docker + Docker Compose on Local
## Giai đoạn 1: Cơ sở hạ tầng cơ bản
- [x] Thiết lập cấu trúc dự án theo mô hình DDD
@ -21,10 +21,9 @@
- [x] Cấu hình Docker và Docker Compose
- [x] HTTP server với Gin
- [x] Database setup với GORM và Postgres
- [ ] Health check API endpoints
- Timeline: Q2/2025
- [x] Health check API endpoints
## Giai đoạn 2: Bảo mật và xác thực (Q2/2025)
## Giai đoạn 2: Bảo mật và xác thực
### 1. Xác thực và Ủy quyền
- [x] **JWT Authentication**
@ -53,46 +52,21 @@
- [ ] Content Security Policy (CSP) tùy chỉnh
- [ ] XSS protection
### 3. Theo dõi và Giám sát
- [ ] **Audit Logging**
- [ ] Ghi log các hoạt động quan trọng
- [ ] Theo dõi đăng nhập thất bại
- [ ] Cảnh báo bảo mật
- [ ] **Monitoring**
- [ ] Tích hợp Prometheus
- [ ] Dashboard giám sát
- [ ] Cảnh báo bất thường
### 4. Cải thiện Hiệu suất
- [ ] **Tối ưu hóa**
- [ ] Redis cho caching
- [ ] Tối ưu truy vấn database
- [ ] Compression response
### Timeline
- Tuần 1-2: Hoàn thiện xác thực & phân quyền
- Tuần 3-4: Triển khai bảo mật API và headers
- Tuần 5-6: Hoàn thiện audit logging và monitoring
- Tuần 7-8: Tối ưu hiệu suất và kiểm thử bảo mật
## Giai đoạn 3: Tự động hóa
- [ ] Unit Test templates và mocks
- [ ] CI/CD với Gitea
- [ ] Automated deployment
- [ ] Linting và code quality checks
- Timeline: Q3/2025
## Giai đoạn 4: Mở rộng tính năng
- [x] Go Feature Flag implementation
- [ ] Notification system
- [ ] Background job processing
- [ ] API documentation
- Timeline: Q3/2025
## Giai đoạn 5: Production readiness
- [x] Performance optimization
- [ ] Monitoring và observability
- [ ] Backup và disaster recovery
- [ ] Security hardening
- Timeline: Q4/2025

View File

@ -1,32 +0,0 @@
# Tóm tắt phiên làm việc - 24/05/2025
## Các file đang mở
1. `docs/roadmap.md` - Đang xem mục tiêu phát triển
2. `configs/config.yaml` - File cấu hình ứng dụng
3. `docs/review.md` - Đang xem phần đánh giá code
## Các thay đổi chính trong phiên
### 1. Cập nhật Roadmap
- Đánh dấu hoàn thành các mục JWT Authentication
- Cập nhật chi tiết Giai đoạn 2 (Bảo mật và xác thực)
- Thêm timeline chi tiết cho từng tuần
### 2. Giải thích luồng xác thực
- Đã giải thích chi tiết về luồng JWT authentication
- Mô tả các endpoint chính và cách hoạt động
- Giải thích về bảo mật token và xử lý lỗi
### 3. Các lệnh đã sử dụng
- `/heyy` - Thảo luận về các bước tiếp theo
- `/yys` - Thử lưu trạng thái (không khả dụng)
## Công việc đang thực hiện
- Đang xem xét phần đánh giá code liên quan đến xử lý lỗi khởi động service
## Ghi chú
- Cần hoàn thiện phần Health Check API
- Cần triển khai API rate limiting và security headers
---
*Tự động tạo lúc: 2025-05-24 12:26*

1
go.mod
View File

@ -3,7 +3,6 @@ module starter-kit
go 1.23.6
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt/v5 v5.2.2

3
go.sum
View File

@ -40,8 +40,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@ -178,7 +176,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=

View File

@ -25,7 +25,7 @@ func (r *userRepository) Create(ctx context.Context, u *user.User) error {
func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
var u user.User
// First get the user
err := r.db.WithContext(ctx).Where("`users`.`id` = ? AND `users`.`deleted_at` IS NULL", id).First(&u).Error
err := r.db.WithContext(ctx).Where("users.id = $1 AND users.deleted_at IS NULL", id).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@ -36,7 +36,7 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, er
// Manually preload roles with the exact SQL format expected by tests
var roles []*role.Role
err = r.db.WithContext(ctx).Raw(
"SELECT * FROM `roles` JOIN `user_roles` ON `user_roles`.`role_id` = `roles`.`id` WHERE `user_roles`.`user_id` = ? AND `roles`.`deleted_at` IS NULL",
"SELECT * FROM roles JOIN user_roles ON user_roles.role_id = roles.id WHERE user_roles.user_id = $1",
id,
).Scan(&roles).Error
@ -50,7 +50,7 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, er
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*user.User, error) {
var u user.User
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "username = ?", username).Error
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "username = $1", username).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@ -59,7 +59,7 @@ func (r *userRepository) GetByUsername(ctx context.Context, username string) (*u
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
var u user.User
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "email = ?", email).Error
err := r.db.WithContext(ctx).Preload("Roles").First(&u, "email = $1", email).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@ -71,28 +71,28 @@ func (r *userRepository) Update(ctx context.Context, u *user.User) error {
}
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&user.User{}, "id = ?", id).Error
return r.db.WithContext(ctx).Delete(&user.User{}, "id = $1", id).Error
}
func (r *userRepository) AddRole(ctx context.Context, userID string, roleID int) error {
func (r *userRepository) AddRole(ctx context.Context, userID string, roleID string) error {
return r.db.WithContext(ctx).Exec(
"INSERT INTO `user_roles` (`user_id`, `role_id`) VALUES (?, ?) ON CONFLICT DO NOTHING",
"INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT (user_id, role_id) DO NOTHING",
userID, roleID,
).Error
}
func (r *userRepository) RemoveRole(ctx context.Context, userID string, roleID int) error {
func (r *userRepository) RemoveRole(ctx context.Context, userID string, roleID string) error {
return r.db.WithContext(ctx).Exec(
"DELETE FROM user_roles WHERE user_id = ? AND role_id = ?",
"DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2",
userID, roleID,
).Error
}
func (r *userRepository) HasRole(ctx context.Context, userID string, roleID int) (bool, error) {
func (r *userRepository) HasRole(ctx context.Context, userID string, roleID string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&user.User{}).
Joins("JOIN user_roles ON user_roles.user_id = users.id").
Where("users.id = ? AND user_roles.role_id = ?", userID, roleID).
Where("users.id = $1 AND user_roles.role_id = $2", userID, roleID).
Count(&count).Error
return count > 0, err
@ -101,6 +101,6 @@ func (r *userRepository) HasRole(ctx context.Context, userID string, roleID int)
func (r *userRepository) UpdateLastLogin(ctx context.Context, userID string) error {
now := gorm.Expr("NOW()")
return r.db.WithContext(ctx).Model(&user.User{}).
Where("id = ?", userID).
Where("id = $1", userID).
Update("last_login_at", now).Error
}

View File

@ -4,7 +4,7 @@ import "time"
// Role đại diện cho một vai trò trong hệ thống
type Role struct {
ID int `json:"id" gorm:"primaryKey"`
ID string `json:"id" gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
Name string `json:"name" gorm:"size:50;uniqueIndex;not null"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`

View File

@ -25,13 +25,13 @@ type Repository interface {
Delete(ctx context.Context, id string) error
// AddRole thêm vai trò cho người dùng
AddRole(ctx context.Context, userID string, roleID int) error
AddRole(ctx context.Context, userID string, roleID string) error
// RemoveRole xóa vai trò của người dùng
RemoveRole(ctx context.Context, userID string, roleID int) error
RemoveRole(ctx context.Context, userID string, roleID string) error
// HasRole kiểm tra người dùng có vai trò không
HasRole(ctx context.Context, userID string, roleID int) (bool, error)
HasRole(ctx context.Context, userID string, roleID string) (bool, error)
// UpdateLastLogin cập nhật thời gian đăng nhập cuối cùng
UpdateLastLogin(ctx context.Context, userID string) error

View File

@ -38,7 +38,13 @@ func NewConnection(cfg *config.DatabaseConfig) (*gorm.DB, error) {
case "postgres":
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Database, cfg.SSLMode)
dbInstance, dbErr = gorm.Open(postgres.Open(dsn), &gorm.Config{})
// Cấu hình GORM cho PostgreSQL với quote style phù hợp
dbInstance, dbErr = gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",

View File

@ -53,17 +53,17 @@ func (m *MockUserRepo) UpdateLastLogin(ctx context.Context, id string) error {
return args.Error(0)
}
func (m *MockUserRepo) AddRole(ctx context.Context, userID string, roleID int) error {
func (m *MockUserRepo) AddRole(ctx context.Context, userID string, roleID string) error {
args := m.Called(ctx, userID, roleID)
return args.Error(0)
}
func (m *MockUserRepo) RemoveRole(ctx context.Context, userID string, roleID int) error {
func (m *MockUserRepo) RemoveRole(ctx context.Context, userID string, roleID string) error {
args := m.Called(ctx, userID, roleID)
return args.Error(0)
}
func (m *MockUserRepo) HasRole(ctx context.Context, userID string, roleID int) (bool, error) {
func (m *MockUserRepo) HasRole(ctx context.Context, userID string, roleID string) (bool, error) {
args := m.Called(ctx, userID, roleID)
return args.Bool(0), args.Error(1)
}
@ -142,7 +142,7 @@ func TestAuthService_Register(t *testing.T) {
// Mock GetByName - role exists
mr.On("GetByName", mock.Anything, role.User).
Return(&role.Role{ID: 1, Name: role.User}, nil)
Return(&role.Role{ID: "d619c5d7-7d3d-4a91-85af-7f974a2ce5c0", Name: role.User}, nil)
// Mock AddRole
mu.On("AddRole", mock.Anything, mock.Anything, mock.Anything).

View File

@ -4,218 +4,209 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"starter-kit/internal/adapter/persistence"
"starter-kit/internal/domain/role"
"starter-kit/internal/domain/user"
"starter-kit/internal/service"
"starter-kit/internal/transport/http/dto"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"github.com/stretchr/testify/mock"
)
// mock user repository với khả năng hook
type mockUserRepo struct {
user.Repository // nhúng interface để implement tự động
CreateFunc func(ctx context.Context, u *user.User) error
GetByIDFunc func(ctx context.Context, id string) (*user.User, error)
AddRoleFunc func(ctx context.Context, userID string, roleID int) error
// MockAuthService là mock của AuthService
type MockAuthService struct {
mock.Mock
}
func (m *mockUserRepo) Create(ctx context.Context, u *user.User) error {
if m.CreateFunc != nil {
return m.CreateFunc(ctx, u)
// Register mock hàm Register
func (m *MockAuthService) Register(ctx context.Context, req service.RegisterRequest) (*user.User, error) {
args := m.Called(ctx, req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return nil
return args.Get(0).(*user.User), args.Error(1)
}
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*user.User, error) {
if m.GetByIDFunc != nil {
return m.GetByIDFunc(ctx, id)
// Login mock hàm Login
func (m *MockAuthService) Login(ctx context.Context, username, password string) (string, string, error) {
args := m.Called(ctx, username, password)
return args.String(0), args.String(1), args.Error(2)
}
// RefreshToken mock hàm RefreshToken
func (m *MockAuthService) RefreshToken(refreshToken string) (string, string, error) {
args := m.Called(refreshToken)
return args.String(0), args.String(1), args.Error(2)
}
// ValidateToken mock hàm ValidateToken
func (m *MockAuthService) ValidateToken(tokenString string) (*service.Claims, error) {
args := m.Called(tokenString)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return nil, nil
return args.Get(0).(*service.Claims), args.Error(1)
}
func (m *mockUserRepo) AddRole(ctx context.Context, userID string, roleID int) error {
if m.AddRoleFunc != nil {
return m.AddRoleFunc(ctx, userID, roleID)
}
return nil
}
func TestRegisterHandler(t *testing.T) {
// Thiết lập
func TestRegister(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// UUID cố định cho bài test
testUserID := "123e4567-e89b-12d3-a456-426614174000"
t.Run("Test_Register_Duplicate_Email", func(t *testing.T) {
// Arrange
mockAuthSvc := new(MockAuthService)
handler := NewAuthHandler(mockAuthSvc)
// Tạo mock database
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
t.Fatalf("Không thể tạo mock database: %v", err)
}
defer func() { _ = db.Close() }()
// Tạo request body
reqBody := dto.RegisterRequest{
Username: "testuser",
Email: "existing@example.com",
Password: "password123",
FullName: "Test User",
}
jsonBody, _ := json.Marshal(reqBody)
// Kết nối GORM
gormDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
t.Fatalf("Không thể kết nối GORM: %v", err)
}
// Setup mock behavior - trả về lỗi email đã tồn tại
mockAuthSvc.On("Register", mock.Anything, service.RegisterRequest(reqBody)).
Return(nil, errors.New("email already exists"))
// Tạo repositories thật sẽ kết nối với mock DB
realUserRepo := persistence.NewUserRepository(gormDB)
roleRepo := persistence.NewRoleRepository(gormDB)
// Tạo mock repository với đầy đủ các phương thức cần thiết
mockedUserRepo := &mockUserRepo{
Repository: realUserRepo, // delegate các phương thức còn lại
CreateFunc: func(ctx context.Context, u *user.User) error {
// Chú ý: Trong thực tế, ID sẽ được tạo bởi DB (uuid_generate_v4())
// Nhưng vì đây là test, chúng ta cần giả lập việc DB thiết lập ID sau khi INSERT
// Gọi repository thật để thực thi SQL
err := realUserRepo.Create(ctx, u)
// Gán ID cố định sau khi tạo, giả lập việc DB tạo và trả về ID
u.ID = testUserID
return err
},
GetByIDFunc: func(ctx context.Context, id string) (*user.User, error) {
// Tạo user đủ thông tin với role đã preload
userRole := &role.Role{ID: 1, Name: "user", Description: "Basic user role"}
u := &user.User{
ID: testUserID,
Username: "testuser",
Email: "test@example.com",
FullName: "Test User",
AvatarURL: "",
IsActive: true,
Roles: []*role.Role{userRole}, // Gán role đã preload
}
return u, nil
},
AddRoleFunc: func(ctx context.Context, userID string, roleID int) error {
// Kiểm tra đảm bảo ID phù hợp
if userID != testUserID {
return fmt.Errorf("expected user ID %s but got %s", testUserID, userID)
}
// Khi chúng ta gọi AddRole của repo thật, nó sẽ thực thi câu lệnh SQL
return realUserRepo.AddRole(ctx, userID, roleID)
},
}
// Tạo service với mock userRepo
jwtSecret := "test-secret-key"
authSvc := service.NewAuthService(mockedUserRepo, roleRepo, jwtSecret, time.Duration(15)*time.Minute)
// Tạo handler
authHandler := NewAuthHandler(authSvc)
// Tạo router
r := gin.Default()
r.POST("/api/v1/auth/register", authHandler.Register)
// Dữ liệu đăng ký
registerData := dto.RegisterRequest{
Username: "testuser",
Email: "test@example.com",
Password: "password123",
FullName: "Test User",
}
// Chuyển đổi dữ liệu thành JSON
jsonData, err := json.Marshal(registerData)
if err != nil {
t.Fatalf("Lỗi chuyển đổi JSON: %v", err)
}
t.Run("Đăng ký tài khoản mới thành công", func(t *testing.T) {
// Setup các mong đợi SQL match chính xác với GORM theo logs và UserRepository implementation
// 1. Kiểm tra xem username đã tồn tại chưa (userRepo.GetByUsername)
mock.ExpectQuery("SELECT \\* FROM `users` WHERE username = \\? ORDER BY `users`\\.`id` LIMIT \\?").
WithArgs("testuser", 1).
WillReturnError(gorm.ErrRecordNotFound) // Username 'testuser' chưa tồn tại
// 2. Kiểm tra xem email đã tồn tại chưa (userRepo.GetByEmail)
mock.ExpectQuery("SELECT \\* FROM `users` WHERE email = \\? ORDER BY `users`\\.`id` LIMIT \\?").
WithArgs("test@example.com", 1).
WillReturnError(gorm.ErrRecordNotFound) // Email 'test@example.com' chưa tồn tại
// --- Sequence of operations after successful username/email checks and password hashing ---
// 3. Transaction for userRepo.Create (Implicit transaction by GORM)
mock.ExpectBegin()
// 4. Tạo user mới (userRepo.Create)
// Khi không đặt trước ID, GORM không đưa ID vào SQL, để DB tạo UUID tự động
mock.ExpectExec("^INSERT INTO `users` \\(`username`,`email`,`password_hash`,`full_name`,`avatar_url`,`is_active`,`last_login_at`,`created_at`,`updated_at`,`deleted_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?,\\?\\)").
WithArgs(
"testuser", // username
"test@example.com", // email
sqlmock.AnyArg(), // password_hash
"Test User", // full_name
"", // avatar_url
true, // is_active
sqlmock.AnyArg(), // last_login_at
sqlmock.AnyArg(), // created_at
sqlmock.AnyArg(), // updated_at
sqlmock.AnyArg(), // deleted_at
).
WillReturnResult(sqlmock.NewResult(0, 1)) // UUID không có sequence ID, chỉ cần 1 row affected
mock.ExpectCommit()
// 5. Lấy role mặc định 'user' (roleRepo.GetByName)
mock.ExpectQuery("SELECT \\* FROM `roles` WHERE name = \\? ORDER BY `roles`\\.`id` LIMIT \\?").
WithArgs("user", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "description", "created_at", "updated_at", "deleted_at"}).
AddRow(1, "user", "Basic user role", time.Now(), time.Now(), nil))
// 6. Thêm role cho user (userRepo.AddRole -> user_roles table)
// GORM's Create for user_roles có thể dùng 'INSERT ... ON CONFLICT'
mock.ExpectExec("INSERT INTO `user_roles` \\(`user_id`, `role_id`\\) VALUES \\(\\?\\, \\?\\)").
WithArgs(testUserID, 1). // user_id (UUID string), role_id (int)
WillReturnResult(sqlmock.NewResult(0, 1)) // Thêm thành công 1 row
// Chú ý: Vì chúng ta đã override mockUserRepo.GetByID và mockUserRepo.AddRole
// nên không cần mock SQL cho các query lấy thông tin user sau khi tạo
// mockUserRepo.GetByID sẽ trả về user đã có role được preload
// Tạo request
req, _ := http.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(jsonData))
// Setup HTTP request
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Thực thi request
r.ServeHTTP(w, req)
// Setup Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Kiểm tra kết quả
assert.Equal(t, http.StatusCreated, w.Code, "Status code phải là 201")
// Act
handler.Register(c)
// Parse JSON response
// Assert
// Kiểm tra response status code
assert.Equal(t, http.StatusConflict, w.Code)
// Kiểm tra response body
var response dto.ErrorResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "email already exists", response.Error)
mockAuthSvc.AssertExpectations(t)
})
t.Run("Test_Register_Duplicate_Username", func(t *testing.T) {
// Arrange
mockAuthSvc := new(MockAuthService)
handler := NewAuthHandler(mockAuthSvc)
// Tạo request body
reqBody := dto.RegisterRequest{
Username: "existinguser",
Email: "new@example.com",
Password: "password123",
FullName: "Test User",
}
jsonBody, _ := json.Marshal(reqBody)
// Setup mock behavior - trả về lỗi username đã tồn tại
mockAuthSvc.On("Register", mock.Anything, service.RegisterRequest(reqBody)).
Return(nil, errors.New("username already exists"))
// Setup HTTP request
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Setup Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Act
handler.Register(c)
// Assert
// Kiểm tra response status code
assert.Equal(t, http.StatusConflict, w.Code)
// Kiểm tra response body
var response dto.ErrorResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "username already exists", response.Error)
mockAuthSvc.AssertExpectations(t)
})
// Thêm một test case cho trường hợp đăng ký thành công để đảm bảo tính đầy đủ
t.Run("Test_Register_Success", func(t *testing.T) {
// Arrange
mockAuthSvc := new(MockAuthService)
handler := NewAuthHandler(mockAuthSvc)
// Tạo request body
reqBody := dto.RegisterRequest{
Username: "newuser",
Email: "new@example.com",
Password: "password123",
FullName: "New User",
}
jsonBody, _ := json.Marshal(reqBody)
// Tạo một user đại diện cho kết quả trả về khi đăng ký thành công
createdAt := time.Now()
mockUser := &user.User{
ID: "123e4567-e89b-12d3-a456-426614174000", // UUID string format
Username: reqBody.Username,
Email: reqBody.Email,
PasswordHash: "hashed_password",
FullName: reqBody.FullName,
IsActive: true,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Roles: []*role.Role{
{
ID: "234e5678-e89b-12d3-a456-426614174001",
Name: "user",
},
},
}
// Setup mock behavior - trả về user đã tạo thành công
mockAuthSvc.On("Register", mock.Anything, service.RegisterRequest(reqBody)).
Return(mockUser, nil)
// Setup HTTP request
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Setup Gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Act
handler.Register(c)
// Assert
// Kiểm tra response status code
assert.Equal(t, http.StatusCreated, w.Code)
// Kiểm tra response body
var response dto.UserResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err, "Parse JSON không có lỗi")
assert.NoError(t, err)
assert.Equal(t, mockUser.ID, response.ID)
assert.Equal(t, mockUser.Username, response.Username)
assert.Equal(t, mockUser.Email, response.Email)
// Kiểm tra thông tin phản hồi
assert.Equal(t, registerData.Username, response.Username, "Username phải khớp")
assert.Equal(t, registerData.Email, response.Email, "Email phải khớp")
assert.Equal(t, registerData.FullName, response.FullName, "FullName phải khớp")
assert.NotEmpty(t, response.ID, "ID không được rỗng")
// Kiểm tra nếu có SQL expectations nào chưa được đáp ứng
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("Các expectations chưa được đáp ứng: %s", err)
}
mockAuthSvc.AssertExpectations(t)
})
}

View File

@ -0,0 +1,106 @@
package middleware
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
// ResourceOwnerCheck kiểm tra người dùng có phải là chủ sở hữu của tài nguyên không
// hoặc có quyền admin để truy cập tài nguyên của người khác
func (m *AuthMiddleware) ResourceOwnerCheck(paramName string) gin.HandlerFunc {
return func(c *gin.Context) {
// Lấy ID tài nguyên từ URL parameter
resourceID := c.Param(paramName)
if resourceID == "" {
// Không có param ID, bỏ qua kiểm tra
c.Next()
return
}
// Lấy thông tin user hiện tại từ context
claims, err := GetUserFromContext(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized access",
})
return
}
// Kiểm tra nếu người dùng là chủ sở hữu
currentUserID := claims.UserID
// Trường hợp 1: Người dùng là chủ sở hữu tài nguyên
if resourceID == currentUserID {
c.Next()
return
}
// Trường hợp 2: Kiểm tra nếu người dùng có role admin
isAdmin := false
for _, role := range claims.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if isAdmin {
// Có quyền admin, cho phép truy cập
c.Next()
return
}
// Không có quyền truy cập
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("You don't have permission to access this %s", paramName),
})
}
}
// RequireOwnerOrRole kiểm tra người dùng là chủ sở hữu hoặc có một trong các vai trò được chỉ định
func (m *AuthMiddleware) RequireOwnerOrRole(paramName string, roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
// Lấy ID tài nguyên từ URL parameter
resourceID := c.Param(paramName)
if resourceID == "" {
// Không có param ID, sử dụng RequireRole middleware
mRoleCheck := m.RequireRole(roles...)
mRoleCheck(c)
return
}
// Lấy thông tin user hiện tại từ context
claims, err := GetUserFromContext(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized access",
})
return
}
// Kiểm tra nếu người dùng là chủ sở hữu
currentUserID := claims.UserID
if resourceID == currentUserID {
c.Next()
return
}
// Không phải chủ sở hữu, kiểm tra role
for _, role := range roles {
for _, userRole := range claims.Roles {
if userRole == role {
// Có role phù hợp
c.Next()
return
}
}
}
// Không có quyền truy cập
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("Require ownership of %s or one of these roles: %v", paramName, roles),
})
}
}

View File

@ -0,0 +1,162 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"starter-kit/internal/service"
)
func TestResourceOwnerCheck_Success_SameUser(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// Sử dụng struct trống thay vì mock vì trong test này ta không cần gọi bất kỳ phương thức nào của AuthService
authMiddleware := &AuthMiddleware{}
r := gin.New()
r.Use(func(c *gin.Context) {
// Simulate claims being set by Authenticate middleware
claims := &service.Claims{
UserID: "user123",
Username: "testuser",
Roles: []string{"user"},
}
c.Set(ContextKeyUser, claims)
})
// Add a route with resource owner check
r.GET("/users/:id", authMiddleware.ResourceOwnerCheck("id"), func(c *gin.Context) {
c.String(http.StatusOK, "Access granted")
})
// Test with the same user ID
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/user123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "Access granted", w.Body.String())
}
func TestResourceOwnerCheck_Success_AdminUser(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// Sử dụng struct trống thay vì mock vì trong test này ta không cần gọi bất kỳ phương thức nào của AuthService
authMiddleware := &AuthMiddleware{}
r := gin.New()
r.Use(func(c *gin.Context) {
// Simulate claims with admin role
claims := &service.Claims{
UserID: "admin456",
Username: "adminuser",
Roles: []string{"admin"},
}
c.Set(ContextKeyUser, claims)
})
// Add a route with resource owner check
r.GET("/users/:id", authMiddleware.ResourceOwnerCheck("id"), func(c *gin.Context) {
c.String(http.StatusOK, "Access granted")
})
// Test with different user ID but admin role
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/user123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "Access granted", w.Body.String())
}
func TestResourceOwnerCheck_Forbidden(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// Sử dụng struct trống thay vì mock vì trong test này ta không cần gọi bất kỳ phương thức nào của AuthService
authMiddleware := &AuthMiddleware{}
r := gin.New()
r.Use(func(c *gin.Context) {
// Regular user trying to access another user's resource
claims := &service.Claims{
UserID: "user456",
Username: "anotheruser",
Roles: []string{"user"},
}
c.Set(ContextKeyUser, claims)
})
// Add a route with resource owner check
r.GET("/users/:id", authMiddleware.ResourceOwnerCheck("id"), func(c *gin.Context) {
c.String(http.StatusOK, "Access granted")
})
// Test with different user ID
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/user123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "You don't have permission")
}
func TestRequireOwnerOrRole_Owner_Success(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// Sử dụng struct trống thay vì mock vì trong test này ta không cần gọi bất kỳ phương thức nào của AuthService
authMiddleware := &AuthMiddleware{}
r := gin.New()
r.Use(func(c *gin.Context) {
claims := &service.Claims{
UserID: "user123",
Username: "testuser",
Roles: []string{"user"},
}
c.Set(ContextKeyUser, claims)
})
// Add a route with require owner or role check
r.GET("/users/:id", authMiddleware.RequireOwnerOrRole("id", "admin", "manager"), func(c *gin.Context) {
c.String(http.StatusOK, "Access granted")
})
// Test as owner
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/user123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestRequireOwnerOrRole_Role_Success(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// Sử dụng struct trống thay vì mock vì trong test này ta không cần gọi bất kỳ phương thức nào của AuthService
authMiddleware := &AuthMiddleware{}
r := gin.New()
r.Use(func(c *gin.Context) {
claims := &service.Claims{
UserID: "manager789",
Username: "manageruser",
Roles: []string{"manager"},
}
c.Set(ContextKeyUser, claims)
})
// Add a route with require owner or role check
r.GET("/users/:id", authMiddleware.RequireOwnerOrRole("id", "admin", "manager"), func(c *gin.Context) {
c.String(http.StatusOK, "Access granted")
})
// Test with allowed role
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/user123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}

View File

@ -87,5 +87,24 @@ func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
// api.PUT("/profile", userHandler.UpdateProfile)
}
// Special route to list all API endpoints (only in development mode)
if cfg.App.Environment != "production" {
router.GET("/routes", func(c *gin.Context) {
routes := []map[string]string{}
for _, routeInfo := range router.Routes() {
routes = append(routes, map[string]string{
"method": routeInfo.Method,
"path": routeInfo.Path,
})
}
c.JSON(200, gin.H{
"routes": routes,
"count": len(routes),
})
})
}
return router
}

View File

@ -1,5 +1,7 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
@ -7,8 +9,8 @@ CREATE TABLE roles (
);
-- Insert default roles
INSERT INTO roles (name, description) VALUES
('admin', 'Quản trị viên hệ thống'),
('manager', 'Quản lý'),
('user', 'Người dùng thông thường'),
('guest', 'Khách');
INSERT INTO roles (id, name, description) VALUES
(uuid_generate_v4(), 'admin', 'Quản trị viên hệ thống'),
(uuid_generate_v4(), 'manager', 'Quản lý'),
(uuid_generate_v4(), 'user', 'Người dùng thông thường'),
(uuid_generate_v4(), 'guest', 'Khách');

View File

@ -1,7 +1,7 @@
-- Tạo bảng mà không có ràng buộc
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL,
role_id INTEGER NOT NULL,
role_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id)
);