2026-06-14 17:28:13 +08:00
|
|
|
package api_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
|
|
|
|
|
"github.com/hhs/camtalk/internal/api"
|
|
|
|
|
"github.com/hhs/camtalk/internal/auth"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// mockAuthService 实现 auth.Service 接口,用于 API 测试。
|
|
|
|
|
type mockAuthService struct {
|
|
|
|
|
RegisterFunc func(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error)
|
|
|
|
|
LoginFunc func(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error)
|
|
|
|
|
RefreshFunc func(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error)
|
|
|
|
|
LogoutFunc func(ctx context.Context, userID, refreshToken string) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockAuthService) Register(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
return m.RegisterFunc(ctx, req)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockAuthService) Login(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
return m.LoginFunc(ctx, req)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockAuthService) Refresh(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
return m.RefreshFunc(ctx, req)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockAuthService) Logout(ctx context.Context, userID, refreshToken string) error {
|
|
|
|
|
return m.LogoutFunc(ctx, userID, refreshToken)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newTestRouter 创建带 AuthHandler 路由的测试 Gin 引擎。
|
|
|
|
|
func newTestRouter(svc auth.Service) *gin.Engine {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
r := gin.New()
|
|
|
|
|
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
|
|
|
|
h := api.NewAuthHandler(svc, tm)
|
2026-06-21 00:00:24 +08:00
|
|
|
h.RegisterRoutes(r.Group("/api"), nil) // 测试时不启用限流
|
2026-06-14 17:28:13 +08:00
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newTestRouterWithToken 创建带 AuthHandler 路由的测试引擎,同时返回 TokenManager 以便生成测试 token。
|
|
|
|
|
func newTestRouterWithToken(svc auth.Service) (*gin.Engine, *auth.TokenManager) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
r := gin.New()
|
|
|
|
|
tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour)
|
|
|
|
|
h := api.NewAuthHandler(svc, tm)
|
2026-06-21 00:00:24 +08:00
|
|
|
h.RegisterRoutes(r.Group("/api"), nil) // 测试时不启用限流
|
2026-06-14 17:28:13 +08:00
|
|
|
return r, tm
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sampleAuthResponse() *auth.AuthResponse {
|
|
|
|
|
return &auth.AuthResponse{
|
|
|
|
|
User: auth.UserResponse{
|
|
|
|
|
ID: "user-123",
|
|
|
|
|
Username: "alice",
|
|
|
|
|
},
|
|
|
|
|
AccessToken: "access-token",
|
|
|
|
|
RefreshToken: "refresh-token",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Register ---
|
|
|
|
|
|
|
|
|
|
func TestRegister_Success(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
RegisterFunc: func(_ context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
assert.Equal(t, "alice", req.Username)
|
|
|
|
|
assert.Equal(t, "password123", req.Password)
|
|
|
|
|
return sampleAuthResponse(), nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
|
var resp auth.AuthResponse
|
|
|
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
|
|
|
assert.Equal(t, "alice", resp.User.Username)
|
|
|
|
|
assert.NotEmpty(t, resp.AccessToken)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_InvalidInput_EmptyBody(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "INVALID_INPUT")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_InvalidInput_UsernameTooShort(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RegisterRequest{Username: "ab", Password: "password123"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "username must be 3-64 characters")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_InvalidInput_PasswordTooShort(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "short"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "password must be 8-72 characters")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_UsernameTaken(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
RegisterFunc: func(_ context.Context, _ auth.RegisterRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
return nil, auth.ErrUsernameTaken
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusConflict, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "USERNAME_TAKEN")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Login ---
|
|
|
|
|
|
|
|
|
|
func TestLogin_Success(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
LoginFunc: func(_ context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
assert.Equal(t, "alice", req.Username)
|
|
|
|
|
assert.Equal(t, "password123", req.Password)
|
|
|
|
|
return sampleAuthResponse(), nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "password123"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
|
var resp auth.AuthResponse
|
|
|
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
|
|
|
assert.Equal(t, "alice", resp.User.Username)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogin_InvalidCredentials(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
LoginFunc: func(_ context.Context, _ auth.LoginRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
return nil, auth.ErrInvalidCredentials
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "wrong-password"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "INVALID_CREDENTIALS")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Refresh ---
|
|
|
|
|
|
|
|
|
|
func TestRefresh_Success(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
RefreshFunc: func(_ context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
assert.Equal(t, "some-refresh-token", req.RefreshToken)
|
|
|
|
|
return sampleAuthResponse(), nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "some-refresh-token"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRefresh_MissingToken(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: ""})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "refresh_token is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRefresh_UsedToken(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
RefreshFunc: func(_ context.Context, _ auth.RefreshRequest) (*auth.AuthResponse, error) {
|
|
|
|
|
return nil, auth.ErrRefreshTokenUsed
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "used-token"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "INVALID_TOKEN")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Logout ---
|
|
|
|
|
|
|
|
|
|
func TestLogout_Success(t *testing.T) {
|
|
|
|
|
logoutCalled := false
|
|
|
|
|
svc := &mockAuthService{
|
|
|
|
|
LogoutFunc: func(_ context.Context, userID, refreshToken string) error {
|
|
|
|
|
assert.Equal(t, "user-123", userID)
|
|
|
|
|
assert.Equal(t, "refresh-token-to-revoke", refreshToken)
|
|
|
|
|
logoutCalled = true
|
|
|
|
|
return nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
r, tm := newTestRouterWithToken(svc)
|
|
|
|
|
|
|
|
|
|
// 生成有效 token
|
|
|
|
|
access, _, err := tm.GeneratePair("user-123", "alice")
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(map[string]string{"refresh_token": "refresh-token-to-revoke"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+access)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
|
assert.True(t, logoutCalled)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "logged out successfully")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogout_MissingAuth(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{}
|
|
|
|
|
r := newTestRouter(svc)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(map[string]string{"refresh_token": "some-token"})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogout_MissingRefreshToken(t *testing.T) {
|
|
|
|
|
svc := &mockAuthService{}
|
|
|
|
|
r, tm := newTestRouterWithToken(svc)
|
|
|
|
|
|
|
|
|
|
access, _, err := tm.GeneratePair("user-123", "alice")
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(map[string]string{"refresh_token": ""})
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+access)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
|
assert.Contains(t, w.Body.String(), "refresh_token is required")
|
|
|
|
|
}
|