From 7803b2faf07695e0e2c8a5540acc73d492808cb3 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 13:45:04 +0800 Subject: [PATCH] refactor(api): introduce v1 response contract --- backend/etc/agent.dev.yaml | 12 +++ backend/internal/config/config.go | 15 ++-- backend/internal/config/config_test.go | 9 +- backend/internal/httpx/params.go | 18 ++++ backend/internal/httpx/response.go | 21 +++++ backend/internal/httpx/response_test.go | 61 +++++++++++++ backend/internal/httpx/router.go | 38 ++++---- backend/internal/httpx/router_test.go | 87 ++++++++++++++++--- backend/internal/logic/auth/handlers.go | 4 +- backend/internal/logic/auth/handlers_test.go | 19 +++- backend/internal/logic/auth/middleware.go | 13 ++- .../internal/logic/auth/middleware_test.go | 40 +++++++++ .../internal/logic/projects/handlers_test.go | 14 +-- 13 files changed, 303 insertions(+), 48 deletions(-) create mode 100644 backend/internal/httpx/params.go create mode 100644 backend/internal/httpx/response.go create mode 100644 backend/internal/httpx/response_test.go create mode 100644 backend/internal/logic/auth/middleware_test.go diff --git a/backend/etc/agent.dev.yaml b/backend/etc/agent.dev.yaml index a809533..ab8be8b 100644 --- a/backend/etc/agent.dev.yaml +++ b/backend/etc/agent.dev.yaml @@ -5,3 +5,15 @@ storage_dir: "./data/files" auth_secret: "development-auth-secret-change-me" system_ai_key: "" ai_key_encryption_secret: "development-ai-key-secret-change-me" +allowed_origins: + - "http://localhost:5173" + - "http://127.0.0.1:5173" + - "http://localhost:4173" + - "http://127.0.0.1:4173" + - "http://localhost:4174" + - "http://127.0.0.1:4174" + - "http://localhost:4175" + - "http://127.0.0.1:4175" + - "http://localhost:4176" + - "http://127.0.0.1:4176" + - "http://tauri.localhost" diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d9d6f34..c3e2d91 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,13 +9,14 @@ import ( ) type Config struct { - Env string `yaml:"env"` - Port string `yaml:"port"` - DSN string `yaml:"dsn"` - StorageDir string `yaml:"storage_dir"` - AuthSecret string `yaml:"auth_secret"` - SystemAIKey string `yaml:"system_ai_key"` - AIKeyEncryptionSecret string `yaml:"ai_key_encryption_secret"` + Env string `yaml:"env"` + Port string `yaml:"port"` + DSN string `yaml:"dsn"` + StorageDir string `yaml:"storage_dir"` + AuthSecret string `yaml:"auth_secret"` + SystemAIKey string `yaml:"system_ai_key"` + AIKeyEncryptionSecret string `yaml:"ai_key_encryption_secret"` + AllowedOrigins []string `yaml:"allowed_origins"` } func Load() Config { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 1e10237..c9b9efd 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -24,6 +24,7 @@ func TestLoadFromDirDefaultsToDevYAML(t *testing.T) { require.Equal(t, "dev-auth", cfg.AuthSecret) require.Equal(t, "dev-system", cfg.SystemAIKey) require.Equal(t, "dev-ai", cfg.AIKeyEncryptionSecret) + require.Equal(t, []string{"http://localhost:5173", "http://tauri.localhost"}, cfg.AllowedOrigins) } func TestLoadFromDirUsesSENLINAppMode(t *testing.T) { @@ -42,16 +43,22 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) { require.Equal(t, "prod-auth", cfg.AuthSecret) require.Equal(t, "prod-system", cfg.SystemAIKey) require.Equal(t, "prod-ai", cfg.AIKeyEncryptionSecret) + require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins) } func writeConfig(t *testing.T, dir string, name string, env string, port string, databaseURL string, storageDir string, authSecret string, systemAIKey string, aiKeySecret string) { t.Helper() + allowedOrigins := " - http://localhost:5173\n - http://tauri.localhost\n" + if env == "production" { + allowedOrigins = " - https://workbench.example.com\n" + } content := []byte("env: " + env + "\n" + "port: \"" + port + "\"\n" + "dsn: \"" + databaseURL + "\"\n" + "storage_dir: \"" + storageDir + "\"\n" + "auth_secret: \"" + authSecret + "\"\n" + "system_ai_key: \"" + systemAIKey + "\"\n" + - "ai_key_encryption_secret: \"" + aiKeySecret + "\"\n") + "ai_key_encryption_secret: \"" + aiKeySecret + "\"\n" + + "allowed_origins:\n" + allowedOrigins) require.NoError(t, os.WriteFile(filepath.Join(dir, name), content, 0o600)) } diff --git a/backend/internal/httpx/params.go b/backend/internal/httpx/params.go new file mode 100644 index 0000000..45e6494 --- /dev/null +++ b/backend/internal/httpx/params.go @@ -0,0 +1,18 @@ +package httpx + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// IdentityParam 只接受 UUIDv7 路径参数,避免把内部自增 ID 暴露为外部标识。 +func IdentityParam(c *gin.Context, name string) (string, bool) { + identity, err := uuid.Parse(c.Param(name)) + if err != nil || identity.Version() != 7 { + Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") + return "", false + } + return identity.String(), true +} diff --git a/backend/internal/httpx/response.go b/backend/internal/httpx/response.go new file mode 100644 index 0000000..18a9c40 --- /dev/null +++ b/backend/internal/httpx/response.go @@ -0,0 +1,21 @@ +package httpx + +import "github.com/gin-gonic/gin" + +// ErrorBody 描述稳定错误契约中的机器码和可执行中文信息。 +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// ErrorEnvelope 统一包装所有对外 HTTP 错误。 +type ErrorEnvelope struct { + Error ErrorBody `json:"error"` +} + +// Error 中止当前请求并返回标准错误结构,避免向客户端泄露内部错误细节。 +func Error(c *gin.Context, status int, code, message string) { + c.AbortWithStatusJSON(status, ErrorEnvelope{ + Error: ErrorBody{Code: code, Message: message}, + }) +} diff --git a/backend/internal/httpx/response_test.go b/backend/internal/httpx/response_test.go new file mode 100644 index 0000000..85b62cf --- /dev/null +++ b/backend/internal/httpx/response_test.go @@ -0,0 +1,61 @@ +package httpx + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestErrorUsesStableEnvelope(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + + Error(ctx, http.StatusBadRequest, "invalid_request", "请求参数无效") + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.JSONEq(t, `{"error":{"code":"invalid_request","message":"请求参数无效"}}`, recorder.Body.String()) + require.True(t, ctx.IsAborted()) +} + +func TestIdentityParamAcceptsUUIDV7(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Params = gin.Params{{Key: "projectId", Value: "018f0c9a-7b3c-7cc1-98c8-0242ac120002"}} + + identity, ok := IdentityParam(ctx, "projectId") + + require.True(t, ok) + require.Equal(t, "018f0c9a-7b3c-7cc1-98c8-0242ac120002", identity) + require.False(t, ctx.IsAborted()) +} + +func TestIdentityParamRejectsNonUUIDV7(t *testing.T) { + tests := []struct { + name string + value string + }{ + {name: "internal numeric ID", value: "42"}, + {name: "UUID v4", value: "550e8400-e29b-41d4-a716-446655440000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Params = gin.Params{{Key: "projectId", Value: tt.value}} + + identity, ok := IdentityParam(ctx, "projectId") + + require.False(t, ok) + require.Empty(t, identity) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.JSONEq(t, `{"error":{"code":"invalid_request","message":"请求参数无效"}}`, recorder.Body.String()) + }) + } +} diff --git a/backend/internal/httpx/router.go b/backend/internal/httpx/router.go index 1640113..b2fe6ae 100644 --- a/backend/internal/httpx/router.go +++ b/backend/internal/httpx/router.go @@ -2,6 +2,7 @@ package httpx import ( "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -13,10 +14,12 @@ type RouteRegistrar interface { Register(router gin.IRouter) } +// NewRouter 创建仅暴露 /api/v1 业务契约的公开路由。 func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine { return newRouter(cfg, nil, registrars...) } +// NewProtectedRouter 为 /api/v1 业务路由启用签名 session 校验。 func NewProtectedRouter(cfg config.Config, tokenVerifier func(string) (uint, error), registrars ...RouteRegistrar) *gin.Engine { return newRouter(cfg, tokenVerifier, registrars...) } @@ -28,14 +31,14 @@ func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), regi router := gin.New() router.Use(gin.Recovery()) - router.Use(cors()) + router.Use(cors(cfg)) router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - api := router.Group("/api") + api := router.Group("/api/v1") api.GET("/status", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"timestamp_ms": time.Now().UnixMilli()}) + c.JSON(http.StatusOK, gin.H{"timestamp": time.Now().UTC().Format(time.RFC3339Nano)}) }) if tokenVerifier != nil { api.Use(auth.RequireUser(tokenVerifier)) @@ -47,22 +50,27 @@ func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), regi return router } -func cors() gin.HandlerFunc { - allowedOrigins := map[string]bool{ - "http://localhost:5173": true, - "http://127.0.0.1:5173": true, - "http://localhost:4173": true, - "http://127.0.0.1:4173": true, - "http://localhost:4174": true, - "http://127.0.0.1:4174": true, - "http://localhost:4175": true, - "http://127.0.0.1:4175": true, - "http://tauri.localhost": true, +func cors(cfg config.Config) gin.HandlerFunc { + origins := cfg.AllowedOrigins + if len(origins) == 0 && (cfg.Env == "development" || cfg.Env == "dev" || cfg.Env == "test") { + origins = []string{ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://tauri.localhost", + } + } + + // 非开发环境在未配置来源时默认拒绝跨域,防止意外开放浏览器访问。 + allowedOrigins := make(map[string]struct{}, len(origins)) + for _, origin := range origins { + if origin = strings.TrimSpace(origin); origin != "" { + allowedOrigins[origin] = struct{}{} + } } return func(c *gin.Context) { origin := c.GetHeader("Origin") - if allowedOrigins[origin] { + if _, ok := allowedOrigins[origin]; ok { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") diff --git a/backend/internal/httpx/router_test.go b/backend/internal/httpx/router_test.go index cb9834c..2f01012 100644 --- a/backend/internal/httpx/router_test.go +++ b/backend/internal/httpx/router_test.go @@ -23,9 +23,9 @@ func TestHealthz(t *testing.T) { require.JSONEq(t, `{"status":"ok"}`, rec.Body.String()) } -func TestNewRouterRegistersFeatureRoutesUnderAPI(t *testing.T) { +func TestNewRouterRegistersFeatureRoutesUnderAPIV1(t *testing.T) { router := NewRouter(config.Config{Env: "test"}, testRegistrar{}) - req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -34,7 +34,27 @@ func TestNewRouterRegistersFeatureRoutesUnderAPI(t *testing.T) { require.JSONEq(t, `{"pong":true}`, rec.Body.String()) } -func TestAPIStatusReturnsChangingTimestampMillisecondsWithoutAuth(t *testing.T) { +func TestStatusLivesUnderAPIV1(t *testing.T) { + router := NewRouter(config.Config{Env: "test"}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestLegacyAPIRouteIsNotExposed(t *testing.T) { + router := NewRouter(config.Config{Env: "test"}, testRegistrar{}) + req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestAPIStatusReturnsChangingRFC3339UTCTimestampWithoutAuth(t *testing.T) { router := NewProtectedRouter(config.Config{Env: "test"}, func(token string) (uint, error) { return 0, http.ErrNoCookie }) @@ -43,13 +63,14 @@ func TestAPIStatusReturnsChangingTimestampMillisecondsWithoutAuth(t *testing.T) time.Sleep(2 * time.Millisecond) second := getStatusTimestamp(t, router) - require.Greater(t, first, int64(0)) - require.Greater(t, second, first) + require.Equal(t, time.UTC, first.Location()) + require.Equal(t, time.UTC, second.Location()) + require.True(t, second.After(first)) } func TestRouterAddsCORSHeadersForLocalWebClient(t *testing.T) { router := NewRouter(config.Config{Env: "test"}, testRegistrar{}) - req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) req.Header.Set("Origin", "http://localhost:5173") rec := httptest.NewRecorder() @@ -60,11 +81,45 @@ func TestRouterAddsCORSHeadersForLocalWebClient(t *testing.T) { require.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "Authorization") } +func TestAPIV1LoginRemainsPublic(t *testing.T) { + router := NewProtectedRouter(config.Config{Env: "test"}, func(token string) (uint, error) { + return 0, http.ErrNoCookie + }, loginRegistrar{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestRouterUsesConfiguredCORSOrigins(t *testing.T) { + router := NewRouter(config.Config{Env: "test", AllowedOrigins: []string{"https://workbench.example.com"}}, testRegistrar{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) + req.Header.Set("Origin", "https://workbench.example.com") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Equal(t, "https://workbench.example.com", rec.Header().Get("Access-Control-Allow-Origin")) +} + +func TestRouterRejectsUnconfiguredCORSOrigin(t *testing.T) { + router := NewRouter(config.Config{Env: "production", AllowedOrigins: []string{"https://workbench.example.com"}}, testRegistrar{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) + req.Header.Set("Origin", "https://attacker.example.com") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Empty(t, rec.Header().Get("Access-Control-Allow-Origin")) +} + func TestRouterHandlesCORSPreflightBeforeAuth(t *testing.T) { router := NewProtectedRouter(config.Config{Env: "test"}, func(token string) (uint, error) { return 0, http.ErrNoCookie }, testRegistrar{}) - req := httptest.NewRequest(http.MethodOptions, "/api/ping", nil) + req := httptest.NewRequest(http.MethodOptions, "/api/v1/ping", nil) req.Header.Set("Origin", "http://localhost:5173") req.Header.Set("Access-Control-Request-Method", "GET") req.Header.Set("Access-Control-Request-Headers", "Authorization") @@ -85,17 +140,27 @@ func (testRegistrar) Register(router gin.IRouter) { }) } -func getStatusTimestamp(t *testing.T, router http.Handler) int64 { +type loginRegistrar struct{} + +func (loginRegistrar) Register(router gin.IRouter) { + router.POST("/auth/login", func(c *gin.Context) { + c.Status(http.StatusOK) + }) +} + +func getStatusTimestamp(t *testing.T, router http.Handler) time.Time { t.Helper() - req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var payload struct { - TimestampMS int64 `json:"timestamp_ms"` + Timestamp string `json:"timestamp"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) - return payload.TimestampMS + timestamp, err := time.Parse(time.RFC3339Nano, payload.Timestamp) + require.NoError(t, err) + return timestamp } diff --git a/backend/internal/logic/auth/handlers.go b/backend/internal/logic/auth/handlers.go index 4cd2b90..e2ca7e2 100644 --- a/backend/internal/logic/auth/handlers.go +++ b/backend/internal/logic/auth/handlers.go @@ -24,12 +24,12 @@ func (h *Handler) login(c *gin.Context) { Password string `json:"password"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + abortWithError(c, http.StatusBadRequest, "invalid_request", "请求参数无效") return } token, err := h.service.Login(input.Email, input.Password) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + abortWithError(c, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误") return } c.JSON(http.StatusOK, gin.H{"token": token}) diff --git a/backend/internal/logic/auth/handlers_test.go b/backend/internal/logic/auth/handlers_test.go index 8f7b9ef..f811f2a 100644 --- a/backend/internal/logic/auth/handlers_test.go +++ b/backend/internal/logic/auth/handlers_test.go @@ -28,7 +28,7 @@ func TestLoginHandlerReturnsSessionToken(t *testing.T) { body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "password123"}) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -53,13 +53,28 @@ func TestLoginHandlerRejectsInvalidCredentials(t *testing.T) { body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "wrong"}) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusUnauthorized, rec.Code) + require.JSONEq(t, `{"error":{"code":"invalid_credentials","message":"邮箱或密码错误"}}`, rec.Body.String()) +} + +func TestLoginHandlerRejectsInvalidJSONWithStableError(t *testing.T) { + newTestDB(t) + service := auth.NewService("test-secret") + router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"email":`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.JSONEq(t, `{"error":{"code":"invalid_request","message":"请求参数无效"}}`, rec.Body.String()) } func newTestDB(t *testing.T) *gorm.DB { diff --git a/backend/internal/logic/auth/middleware.go b/backend/internal/logic/auth/middleware.go index 8d44011..7242c89 100644 --- a/backend/internal/logic/auth/middleware.go +++ b/backend/internal/logic/auth/middleware.go @@ -11,7 +11,7 @@ const CurrentUserIDKey = "currentUserID" func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc { return func(c *gin.Context) { - if c.Request.Method == http.MethodPost && c.Request.URL.Path == "/api/auth/login" { + if c.Request.Method == http.MethodPost && c.Request.URL.Path == "/api/v1/auth/login" { c.Next() return } @@ -19,12 +19,12 @@ func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc { header := c.GetHeader("Authorization") token := strings.TrimPrefix(header, "Bearer ") if token == "" || token == header { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) + abortWithError(c, http.StatusUnauthorized, "missing_bearer_token", "请先登录后再操作") return } userID, err := tokenVerifier(token) if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid bearer token"}) + abortWithError(c, http.StatusUnauthorized, "invalid_bearer_token", "登录状态无效或已过期,请重新登录") return } c.Set(CurrentUserIDKey, userID) @@ -32,6 +32,13 @@ func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc { } } +// 认证包不能反向依赖路由包;此处保持与 httpx.Error 相同的安全错误边界。 +func abortWithError(c *gin.Context, status int, code, message string) { + c.AbortWithStatusJSON(status, gin.H{ + "error": gin.H{"code": code, "message": message}, + }) +} + func CurrentUserID(c *gin.Context) (uint, bool) { value, ok := c.Get(CurrentUserIDKey) if !ok { diff --git a/backend/internal/logic/auth/middleware_test.go b/backend/internal/logic/auth/middleware_test.go new file mode 100644 index 0000000..0895843 --- /dev/null +++ b/backend/internal/logic/auth/middleware_test.go @@ -0,0 +1,40 @@ +package auth_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "senlinai-agent/backend/internal/logic/auth" +) + +func TestRequireUserReturnsStableErrorWhenBearerTokenIsMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(auth.RequireUser(func(string) (uint, error) { return 0, nil })) + router.GET("/private", func(c *gin.Context) { c.Status(http.StatusOK) }) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/private", nil)) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.JSONEq(t, `{"error":{"code":"missing_bearer_token","message":"请先登录后再操作"}}`, recorder.Body.String()) +} + +func TestRequireUserReturnsStableErrorWhenBearerTokenIsInvalid(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(auth.RequireUser(func(string) (uint, error) { return 0, errors.New("invalid") })) + router.GET("/private", func(c *gin.Context) { c.Status(http.StatusOK) }) + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/private", nil) + req.Header.Set("Authorization", "Bearer invalid") + + router.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.JSONEq(t, `{"error":{"code":"invalid_bearer_token","message":"登录状态无效或已过期,请重新登录"}}`, recorder.Body.String()) +} diff --git a/backend/internal/logic/projects/handlers_test.go b/backend/internal/logic/projects/handlers_test.go index 96fc95f..3af1c40 100644 --- a/backend/internal/logic/projects/handlers_test.go +++ b/backend/internal/logic/projects/handlers_test.go @@ -27,7 +27,7 @@ func TestCreateProjectHandlerPersistsMetadata(t *testing.T) { "description": "RSS exploration workspace", }) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() @@ -47,7 +47,7 @@ func TestCreateTaskHandlerPersistsTask(t *testing.T) { router, project, _ := newProjectsHandlerTestRouter(t) body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"}) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/tasks", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() @@ -72,7 +72,7 @@ func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) { _, err = fileWriter.Write([]byte("hello")) require.NoError(t, err) require.NoError(t, writer.Close()) - req := httptest.NewRequest(http.MethodPost, "/api/projects/1/sources", body) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/sources", body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() @@ -91,7 +91,7 @@ func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) { router, project, _ := newProjectsHandlerTestRouter(t) body, err := json.Marshal(gin.H{"title": "每日整理", "schedule": "0 9 * * *", "enabled": true, "nextRunAt": "2026-07-22T08:00:00Z"}) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/projects/1/cron-plans", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/cron-plans", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() @@ -110,7 +110,7 @@ func TestCreateTagHandlerPersistsProjectTag(t *testing.T) { router, project, _ := newProjectsHandlerTestRouter(t) body, err := json.Marshal(gin.H{"name": "Design"}) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tags", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/tags", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() @@ -126,7 +126,7 @@ func TestCreateTagHandlerPersistsProjectTag(t *testing.T) { func TestListTagsHandlerReturnsProjectTags(t *testing.T) { router, project, _ := newProjectsHandlerTestRouter(t) require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error) - req := httptest.NewRequest(http.MethodGet, "/api/projects/1/tags", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1/tags", nil) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() @@ -145,7 +145,7 @@ func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) { require.NoError(t, models.DBService.Create(&task).Error) body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"}) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPatch, "/api/projects/1/tasks/1", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/1/tasks/1", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder()