diff --git a/backend/internal/api/auth_test.go b/backend/internal/api/auth_test.go new file mode 100644 index 0000000..f49b75a --- /dev/null +++ b/backend/internal/api/auth_test.go @@ -0,0 +1,324 @@ +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) + h.RegisterRoutes(r.Group("/api")) + 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) + h.RegisterRoutes(r.Group("/api")) + 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") +}