fix(backend): harden write registrar boundaries
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
@@ -62,12 +64,23 @@ func (h *CronHandler) create(c *gin.Context) {
|
||||
Title: input.Title, Schedule: input.Schedule, Enabled: input.Enabled, NextRunAt: nextRunAt,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "计划任务参数无效")
|
||||
writeCronError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, cronPlanDTO(*plan))
|
||||
}
|
||||
|
||||
func writeCronError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrCronTitleRequired), errors.Is(err, ErrCronScheduleRequired):
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "计划任务参数无效")
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在")
|
||||
default:
|
||||
httpx.Error(c, http.StatusInternalServerError, "internal_error", "计划任务创建失败")
|
||||
}
|
||||
}
|
||||
|
||||
func cronPlanDTO(plan models.SenlinAgentCronPlan) CronPlanDTO {
|
||||
return CronPlanDTO{
|
||||
ID: plan.Identity, ProjectID: plan.ProjectIdentity, Title: plan.Title, Schedule: plan.Schedule,
|
||||
|
||||
@@ -8,17 +8,23 @@ import (
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTagNameRequired = errors.New("tag name is required")
|
||||
ErrCronTitleRequired = errors.New("cron plan title is required")
|
||||
ErrCronScheduleRequired = errors.New("cron schedule is required")
|
||||
)
|
||||
|
||||
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("cron plan title is required")
|
||||
return nil, ErrCronTitleRequired
|
||||
}
|
||||
schedule := strings.TrimSpace(input.Schedule)
|
||||
if schedule == "" {
|
||||
return nil, errors.New("cron schedule is required")
|
||||
return nil, ErrCronScheduleRequired
|
||||
}
|
||||
plan := &models.SenlinAgentCronPlan{
|
||||
ProjectID: projectID, CreatedBy: ownerID, Title: title, Schedule: schedule,
|
||||
@@ -37,7 +43,7 @@ func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("tag name is required")
|
||||
return nil, ErrTagNameRequired
|
||||
}
|
||||
var existing models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
@@ -56,12 +58,23 @@ func (h *TagHandler) create(c *gin.Context) {
|
||||
}
|
||||
tag, err := h.service.CreateProjectTag(userID, project.ID, input.Name)
|
||||
if err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "标签名称不能为空")
|
||||
writeTagError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name})
|
||||
}
|
||||
|
||||
func writeTagError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrTagNameRequired):
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "标签名称不能为空")
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在")
|
||||
default:
|
||||
httpx.Error(c, http.StatusInternalServerError, "internal_error", "标签创建失败")
|
||||
}
|
||||
}
|
||||
|
||||
func ownedProjectFromRequest(c *gin.Context) (uint, *models.SenlinAgentProject, bool) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -3,12 +3,14 @@ package projects
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
@@ -62,6 +64,75 @@ func TestTagRegistrarRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, "not_found", payload.Error.Code)
|
||||
require.Equal(t, "项目不存在", payload.Error.Message)
|
||||
}
|
||||
|
||||
func TestTagAndCronServicesReturnTypedValidationErrors(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||
project := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "ALPHA"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
service := NewService()
|
||||
|
||||
_, err := service.CreateProjectTag(1, project.ID, " ")
|
||||
require.ErrorIs(t, err, ErrTagNameRequired)
|
||||
_, err = service.CreateCronPlan(1, project.ID, CreateCronPlanInput{Title: " ", Schedule: "0 9 * * *"})
|
||||
require.ErrorIs(t, err, ErrCronTitleRequired)
|
||||
_, err = service.CreateCronPlan(1, project.ID, CreateCronPlanInput{Title: "Daily", Schedule: " "})
|
||||
require.ErrorIs(t, err, ErrCronScheduleRequired)
|
||||
}
|
||||
|
||||
func TestTagRegistrarMapsDatabaseFailureToInternalError(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||
project := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "ALPHA"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:fail_tag_create", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Table == (models.SenlinAgentTag{}).TableName() {
|
||||
tx.AddError(errors.New("forced tag create failure"))
|
||||
}
|
||||
}))
|
||||
router := newProjectWriteHandlerRouter(t, 1, NewTagHandler(NewService()))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/tags", bytes.NewBufferString(`{"name":"设计"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String())
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, "internal_error", payload.Error.Code)
|
||||
require.Equal(t, "标签创建失败", payload.Error.Message)
|
||||
}
|
||||
|
||||
func TestCronRegistrarMapsDatabaseFailureToInternalError(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||
project := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "ALPHA"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:fail_cron_create", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema != nil && tx.Statement.Schema.Table == (models.SenlinAgentCronPlan{}).TableName() {
|
||||
tx.AddError(errors.New("forced cron create failure"))
|
||||
}
|
||||
}))
|
||||
router := newProjectWriteHandlerRouter(t, 1, NewCronHandler(NewService()))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/cron-plans", bytes.NewBufferString(`{"title":"每日整理","schedule":"0 9 * * *"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String())
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, "internal_error", payload.Error.Code)
|
||||
require.Equal(t, "计划任务创建失败", payload.Error.Message)
|
||||
}
|
||||
|
||||
func TestCronRegistrarCreatesCamelCaseIdentityDTO(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user