first commit
This commit is contained in:
120
router/router.go
Normal file
120
router/router.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
userControllers "goaresv3/app/accounts/controllers"
|
||||
blogControllers "goaresv3/app/blog/controllers"
|
||||
settingsControllers "goaresv3/app/settings/controllers"
|
||||
shopControllers "goaresv3/app/shop/controllers"
|
||||
"goaresv3/pkg/middleware"
|
||||
"goaresv3/pkg/swaggerui"
|
||||
)
|
||||
|
||||
// Setup registers all application routes.
|
||||
func Setup(r *gin.Engine) {
|
||||
r.GET("/swagger/*any", swaggerui.Handler())
|
||||
|
||||
// ── Public auth routes ───────────────────────────────────────────────────
|
||||
auth := r.Group("/api/v1/auth")
|
||||
{
|
||||
auth.POST("/register", userControllers.Register)
|
||||
auth.GET("/verify-email", userControllers.VerifyEmail)
|
||||
auth.POST("/login", userControllers.Login)
|
||||
auth.POST("/refresh", userControllers.RefreshToken)
|
||||
auth.GET("/google/login", userControllers.GoogleLogin)
|
||||
auth.GET("/google/callback", userControllers.GoogleCallback)
|
||||
auth.GET("/github/login", userControllers.GitHubLogin)
|
||||
auth.GET("/github/callback", userControllers.GitHubCallback)
|
||||
}
|
||||
|
||||
// ── Public content routes ──────────────────────────────────────────────────
|
||||
public := r.Group("/api/v1")
|
||||
{
|
||||
// Settings (public read)
|
||||
public.GET("/settings", settingsControllers.GetSetting)
|
||||
public.GET("/settings/heroes", settingsControllers.ListHeroes)
|
||||
|
||||
// Shop (public read)
|
||||
public.GET("/shop/categories", shopControllers.ListProductCategories)
|
||||
public.GET("/shop/tags", shopControllers.ListProductTags)
|
||||
public.GET("/shop/products", shopControllers.ListProducts)
|
||||
public.GET("/shop/products/:id", shopControllers.GetProduct)
|
||||
|
||||
// Blog (public read)
|
||||
public.GET("/blog/categories", blogControllers.ListCategories)
|
||||
public.GET("/blog/tags", blogControllers.ListTags)
|
||||
public.GET("/blog/posts", blogControllers.ListPosts)
|
||||
public.GET("/blog/posts/:id", blogControllers.GetPost)
|
||||
}
|
||||
|
||||
// ── Protected routes (require valid access token) ────────────────────────
|
||||
api := r.Group("/api/v1", middleware.AuthRequired())
|
||||
{
|
||||
api.GET("/me", userControllers.Me)
|
||||
|
||||
// Shop (user cart operations)
|
||||
api.GET("/shop/cart", shopControllers.GetMyCart)
|
||||
api.POST("/shop/cart/items", shopControllers.AddCartItem)
|
||||
api.PUT("/shop/cart/items/:itemId", shopControllers.UpdateCartItem)
|
||||
api.DELETE("/shop/cart/items/:itemId", shopControllers.DeleteCartItem)
|
||||
}
|
||||
|
||||
admin := api.Group("", middleware.AdminRequired())
|
||||
{
|
||||
// Settings
|
||||
admin.PUT("/settings", settingsControllers.UpsertSetting)
|
||||
// Hero
|
||||
admin.POST("/settings/heroes", settingsControllers.CreateHero)
|
||||
admin.PUT("/settings/heroes/:id", settingsControllers.UpdateHero)
|
||||
admin.DELETE("/settings/heroes/:id", settingsControllers.DeleteHero)
|
||||
|
||||
// CORS whitelist
|
||||
admin.GET("/settings/cors/whitelist", settingsControllers.ListCorsWhitelists)
|
||||
admin.POST("/settings/cors/whitelist", settingsControllers.CreateCorsWhitelist)
|
||||
admin.PUT("/settings/cors/whitelist/:id", settingsControllers.UpdateCorsWhitelist)
|
||||
admin.DELETE("/settings/cors/whitelist/:id", settingsControllers.DeleteCorsWhitelist)
|
||||
|
||||
// CORS blacklist
|
||||
admin.GET("/settings/cors/blacklist", settingsControllers.ListCorsBlacklists)
|
||||
admin.POST("/settings/cors/blacklist", settingsControllers.CreateCorsBlacklist)
|
||||
admin.PUT("/settings/cors/blacklist/:id", settingsControllers.UpdateCorsBlacklist)
|
||||
admin.DELETE("/settings/cors/blacklist/:id", settingsControllers.DeleteCorsBlacklist)
|
||||
|
||||
// Rate limits
|
||||
admin.GET("/settings/rate-limits", settingsControllers.ListRateLimits)
|
||||
admin.POST("/settings/rate-limits", settingsControllers.CreateRateLimit)
|
||||
admin.PUT("/settings/rate-limits/:id", settingsControllers.UpdateRateLimit)
|
||||
admin.DELETE("/settings/rate-limits/:id", settingsControllers.DeleteRateLimit)
|
||||
|
||||
// Shop categories
|
||||
admin.POST("/shop/categories", shopControllers.CreateProductCategory)
|
||||
admin.PUT("/shop/categories/:id", shopControllers.UpdateProductCategory)
|
||||
admin.DELETE("/shop/categories/:id", shopControllers.DeleteProductCategory)
|
||||
|
||||
// Shop tags
|
||||
admin.POST("/shop/tags", shopControllers.CreateProductTag)
|
||||
admin.PUT("/shop/tags/:id", shopControllers.UpdateProductTag)
|
||||
admin.DELETE("/shop/tags/:id", shopControllers.DeleteProductTag)
|
||||
|
||||
// Shop products
|
||||
admin.POST("/shop/products", shopControllers.CreateProduct)
|
||||
admin.PUT("/shop/products/:id", shopControllers.UpdateProduct)
|
||||
admin.DELETE("/shop/products/:id", shopControllers.DeleteProduct)
|
||||
|
||||
// Blog categories
|
||||
admin.POST("/blog/categories", blogControllers.CreateCategory)
|
||||
admin.PUT("/blog/categories/:id", blogControllers.UpdateCategory)
|
||||
admin.DELETE("/blog/categories/:id", blogControllers.DeleteCategory)
|
||||
|
||||
// Blog tags
|
||||
admin.POST("/blog/tags", blogControllers.CreateTag)
|
||||
admin.PUT("/blog/tags/:id", blogControllers.UpdateTag)
|
||||
admin.DELETE("/blog/tags/:id", blogControllers.DeleteTag)
|
||||
|
||||
// Blog posts
|
||||
admin.POST("/blog/posts", blogControllers.CreatePost)
|
||||
admin.PUT("/blog/posts/:id", blogControllers.UpdatePost)
|
||||
admin.DELETE("/blog/posts/:id", blogControllers.DeletePost)
|
||||
}
|
||||
}
|
||||
321
router/router_test.go
Normal file
321
router/router_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"goaresv3/app/accounts/models"
|
||||
"goaresv3/config"
|
||||
jwtHelper "goaresv3/pkg/jwt"
|
||||
)
|
||||
|
||||
func setupRouterTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:router_test?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}, &models.SocialAccount{}, &models.Profile{}); err != nil {
|
||||
t.Fatalf("failed to migrate models: %v", err)
|
||||
}
|
||||
config.DB = db
|
||||
return db
|
||||
}
|
||||
|
||||
func startFakeSMTP(t *testing.T) (host, port string, done chan error) {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start fake smtp: %v", err)
|
||||
}
|
||||
|
||||
done = make(chan error, 1)
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
w := bufio.NewWriter(conn)
|
||||
write := func(s string) error {
|
||||
if _, err := w.WriteString(s); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
if err := write("220 localhost SMTP ready\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToUpper(cmd), "EHLO") || strings.HasPrefix(strings.ToUpper(cmd), "HELO"):
|
||||
if err := write("250-localhost\r\n250 AUTH PLAIN\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(strings.ToUpper(cmd), "MAIL FROM"):
|
||||
if err := write("250 OK\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(strings.ToUpper(cmd), "RCPT TO"):
|
||||
if err := write("250 OK\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(strings.ToUpper(cmd), "DATA"):
|
||||
if err := write("354 End data with <CR><LF>.<CR><LF>\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
for {
|
||||
d, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(d) == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := write("250 OK queued\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(strings.ToUpper(cmd), "QUIT"):
|
||||
_ = write("221 Bye\r\n")
|
||||
done <- nil
|
||||
return
|
||||
default:
|
||||
if err := write("250 OK\r\n"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
h, p, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse fake smtp address: %v", err)
|
||||
}
|
||||
if _, err := strconv.Atoi(p); err != nil {
|
||||
t.Fatalf("invalid fake smtp port: %v", err)
|
||||
}
|
||||
return h, p, done
|
||||
}
|
||||
|
||||
func TestAllEndpointsFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupRouterTestDB(t)
|
||||
|
||||
host, port, smtpDone := startFakeSMTP(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
t.Setenv("APP_BASE_URL", "http://localhost:8080")
|
||||
t.Setenv("EMAIL_HOST", host)
|
||||
t.Setenv("EMAIL_PORT", port)
|
||||
t.Setenv("EMAIL_HOST_USER", "")
|
||||
t.Setenv("EMAIL_HOST_PASSWORD", "")
|
||||
t.Setenv("EMAIL_USE_TLS", "false")
|
||||
t.Setenv("EMAIL_USE_SSL", "false")
|
||||
t.Setenv("EMAIL_FROM", "noreply@example.com")
|
||||
|
||||
r := gin.New()
|
||||
Setup(r)
|
||||
|
||||
regBody := []byte(`{"username":"ali","email":"ali@example.com","password":"password123","confirm_password":"password123"}`)
|
||||
regReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
|
||||
regReq.Header.Set("Content-Type", "application/json")
|
||||
regResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(regResp, regReq)
|
||||
if regResp.Code != http.StatusCreated {
|
||||
t.Fatalf("register expected 201, got %d body=%s", regResp.Code, regResp.Body.String())
|
||||
}
|
||||
|
||||
if err := <-smtpDone; err != nil {
|
||||
t.Fatalf("fake smtp ended with error: %v", err)
|
||||
}
|
||||
|
||||
invalidVerifyReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=bad-token", nil)
|
||||
invalidVerifyResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(invalidVerifyResp, invalidVerifyReq)
|
||||
if invalidVerifyResp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid verify expected 400, got %d", invalidVerifyResp.Code)
|
||||
}
|
||||
|
||||
loginBody := []byte(`{"email":"ali@example.com","password":"password123"}`)
|
||||
loginBeforeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
loginBeforeReq.Header.Set("Content-Type", "application/json")
|
||||
loginBeforeResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(loginBeforeResp, loginBeforeReq)
|
||||
if loginBeforeResp.Code != http.StatusForbidden {
|
||||
t.Fatalf("login before verify expected 403, got %d", loginBeforeResp.Code)
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", "ali@example.com").First(&user).Error; err != nil {
|
||||
t.Fatalf("failed to fetch registered user: %v", err)
|
||||
}
|
||||
if user.EmailVerifyToken == "" {
|
||||
t.Fatal("expected email verify token to be set")
|
||||
}
|
||||
|
||||
verifyReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token="+user.EmailVerifyToken, nil)
|
||||
verifyResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(verifyResp, verifyReq)
|
||||
if verifyResp.Code != http.StatusOK {
|
||||
t.Fatalf("verify expected 200, got %d", verifyResp.Code)
|
||||
}
|
||||
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(loginResp, loginReq)
|
||||
if loginResp.Code != http.StatusOK {
|
||||
t.Fatalf("login expected 200, got %d body=%s", loginResp.Code, loginResp.Body.String())
|
||||
}
|
||||
|
||||
var loginJSON map[string]any
|
||||
if err := json.Unmarshal(loginResp.Body.Bytes(), &loginJSON); err != nil {
|
||||
t.Fatalf("failed to decode login response: %v", err)
|
||||
}
|
||||
accessToken, _ := loginJSON["access_token"].(string)
|
||||
refreshToken, _ := loginJSON["refresh_token"].(string)
|
||||
if accessToken == "" || refreshToken == "" {
|
||||
t.Fatal("expected access_token and refresh_token in login response")
|
||||
}
|
||||
|
||||
refreshBody := []byte(`{"refresh_token":"` + refreshToken + `"}`)
|
||||
refreshReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(refreshResp, refreshReq)
|
||||
if refreshResp.Code != http.StatusOK {
|
||||
t.Fatalf("refresh expected 200, got %d body=%s", refreshResp.Code, refreshResp.Body.String())
|
||||
}
|
||||
|
||||
meReq := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
meReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
meResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(meResp, meReq)
|
||||
if meResp.Code != http.StatusOK {
|
||||
t.Fatalf("me expected 200, got %d body=%s", meResp.Code, meResp.Body.String())
|
||||
}
|
||||
|
||||
var meJSON map[string]any
|
||||
if err := json.Unmarshal(meResp.Body.Bytes(), &meJSON); err != nil {
|
||||
t.Fatalf("failed to decode me response: %v", err)
|
||||
}
|
||||
if _, ok := meJSON["username"]; !ok {
|
||||
t.Fatal("expected username in /api/v1/me response")
|
||||
}
|
||||
|
||||
swaggerReq := httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)
|
||||
swaggerResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(swaggerResp, swaggerReq)
|
||||
if swaggerResp.Code != http.StatusOK {
|
||||
t.Fatalf("swagger index expected 200, got %d", swaggerResp.Code)
|
||||
}
|
||||
|
||||
initializerReq := httptest.NewRequest(http.MethodGet, "/swagger/swagger-initializer.js", nil)
|
||||
initializerResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(initializerResp, initializerReq)
|
||||
if initializerResp.Code != http.StatusOK {
|
||||
t.Fatalf("swagger initializer expected 200, got %d", initializerResp.Code)
|
||||
}
|
||||
if !strings.Contains(initializerResp.Body.String(), "requestInterceptor") {
|
||||
t.Fatal("expected requestInterceptor in swagger initializer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedEndpointWithoutAuthHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupRouterTestDB(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
|
||||
r := gin.New()
|
||||
Setup(r)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedEndpointWithRawTokenRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupRouterTestDB(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
verified := true
|
||||
user := models.User{UserName: "raw", Email: "raw@example.com", EmailVerified: &verified}
|
||||
if err := config.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to seed user: %v", err)
|
||||
}
|
||||
|
||||
access, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate access token: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
Setup(r)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
req.Header.Set("Authorization", access)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwaggerDisallowsNonGET(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupRouterTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
Setup(r)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/swagger/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user