Compare commits
2 Commits
main
...
chore/opti
| Author | SHA1 | Date | |
|---|---|---|---|
| b572653d97 | |||
| 9a8c40eee2 |
32
.env.example
32
.env.example
@ -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
1
.gitignore
vendored
@ -48,3 +48,4 @@ dist/
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.obsidian
|
||||
|
||||
@ -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"]
|
||||
2
Makefile
2
Makefile
@ -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:
|
||||
|
||||
@ -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)`
|
||||
@ -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
|
||||
63
docker-compose-minimal.yml
Normal file
63
docker-compose-minimal.yml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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
1
go.mod
@ -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
3
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
106
internal/transport/http/middleware/resource_access.go
Normal file
106
internal/transport/http/middleware/resource_access.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
162
internal/transport/http/middleware/resource_access_test.go
Normal file
162
internal/transport/http/middleware/resource_access_test.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user