fix: невалидное имя варианта проекта — предупреждение вместо ошибки

Имя варианта "main" или с недопустимыми символами больше не отклоняет
запрос ошибкой 400 — теперь автоматически конвертируется в валидное
(sanitizeProjectVariantName), а API/UI показывают variant_warning вместо
блокировки создания/переименования варианта.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-09-17 11:56:55 +03:00
co-authored by Claude Sonnet 5
parent 006d7e87c5
commit 2f3bdc609a
6 changed files with 140 additions and 80 deletions
+58 -38
View File
@@ -17,19 +17,21 @@ import (
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
)
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
// invalidVariantCharsRe matches runs of characters not allowed in a variant name, so they
// can be collapsed to a single separator by sanitizeProjectVariantName.
var invalidVariantCharsRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
type ProjectService struct {
localDB *localdb.LocalDB
}
@@ -61,7 +63,10 @@ type ProjectConfigurationsResult struct {
Total float64 `json:"total"`
}
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
// Create creates a project. If req.Variant is not a valid variant name (reserved word
// "main" or disallowed characters) it is auto-converted into a valid one instead of
// rejecting the request; the returned warning is non-empty when that happened.
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, string, error) {
var namePtr *string
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
@@ -71,17 +76,14 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
}
code := strings.TrimSpace(req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
return nil, "", fmt.Errorf("project code is required")
}
if !projectCodeRe.MatchString(code) {
return nil, ErrProjectCodeInvalidChars
}
variant := strings.TrimSpace(req.Variant)
if err := validateProjectVariantName(variant); err != nil {
return nil, err
return nil, "", ErrProjectCodeInvalidChars
}
variant, variantWarning := sanitizeProjectVariantName(strings.TrimSpace(req.Variant))
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
return nil, "", err
}
now := time.Now()
@@ -99,43 +101,44 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
SyncStatus: "pending",
}
if err := s.localDB.SaveProject(localProject); err != nil {
return nil, err
return nil, "", err
}
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
return nil, err
return nil, "", err
}
return localdb.LocalToProject(localProject), nil
return localdb.LocalToProject(localProject), variantWarning, nil
}
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, error) {
// Update updates a project. Like Create, an invalid req.Variant (reserved word "main" or
// disallowed characters) is auto-converted into a valid one instead of rejecting the
// request; the returned warning is non-empty when that happened.
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, string, error) {
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
if err != nil {
return nil, ErrProjectNotFound
return nil, "", ErrProjectNotFound
}
if req.Code != nil {
code := strings.TrimSpace(*req.Code)
if code == "" {
return nil, fmt.Errorf("project code is required")
return nil, "", fmt.Errorf("project code is required")
}
if !projectCodeRe.MatchString(code) {
return nil, ErrProjectCodeInvalidChars
return nil, "", ErrProjectCodeInvalidChars
}
localProject.Code = code
}
var variantWarning string
if req.Variant != nil {
newVariant := strings.TrimSpace(*req.Variant)
// Block renaming of the main variant (empty Variant) — there must always be a main.
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
return nil, ErrCannotRenameMainVariant
}
localProject.Variant = newVariant
if err := validateProjectVariantName(localProject.Variant); err != nil {
return nil, err
return nil, "", ErrCannotRenameMainVariant
}
localProject.Variant, variantWarning = sanitizeProjectVariantName(newVariant)
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
return nil, "", err
}
if req.Name != nil {
@@ -163,12 +166,12 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending"
if err := s.localDB.SaveProject(localProject); err != nil {
return nil, err
return nil, "", err
}
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
return nil, err
return nil, "", err
}
return localdb.LocalToProject(localProject), nil
return localdb.LocalToProject(localProject), variantWarning, nil
}
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
@@ -203,14 +206,31 @@ func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func validateProjectVariantName(variant string) error {
if normalizeProjectVariant(variant) == "main" {
return ErrReservedMainVariant
// sanitizeProjectVariantName converts an invalid variant name (the reserved word "main",
// or a name containing characters outside [A-Za-z0-9._-]) into a valid one, returning a
// warning describing the change. Empty input (the implicit main variant) always passes
// through unchanged with no warning. Invalid characters are collapsed to a single "-";
// if that empties the name, or the result collides with the reserved "main", a "-variant"
// suffix is appended to guarantee a valid, non-reserved result.
func sanitizeProjectVariantName(variant string) (sanitized string, warning string) {
original := strings.TrimSpace(variant)
if original == "" {
return "", ""
}
if variant != "" && !projectCodeRe.MatchString(variant) {
return ErrProjectVariantInvalidChars
sanitized = original
if !projectCodeRe.MatchString(sanitized) {
sanitized = strings.Trim(invalidVariantCharsRe.ReplaceAllString(sanitized, "-"), "-")
}
return nil
if sanitized == "" {
sanitized = "variant"
}
if normalizeProjectVariant(sanitized) == "main" {
sanitized += "-variant"
}
if sanitized == original {
return sanitized, ""
}
return sanitized, fmt.Sprintf("имя варианта %q недопустимо — использовано %q", original, sanitized)
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
+50 -10
View File
@@ -1,37 +1,47 @@
package services
import (
"errors"
"path/filepath"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
// TestProjectServiceCreateSanitizesReservedMainVariant verifies that naming a new
// variant "main" no longer errors: the name is auto-converted to a valid one and a
// warning is returned instead of the request being rejected.
func TestProjectServiceCreateSanitizesReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
_, err = service.Create("tester", &CreateProjectRequest{
project, warning, err := service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "main",
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
if err != nil {
t.Fatalf("create project: %v", err)
}
if warning == "" {
t.Fatalf("expected a warning about the reserved variant name")
}
if project.Variant == "" || normalizeProjectVariant(project.Variant) == "main" {
t.Fatalf("expected variant to be converted away from 'main', got %q", project.Variant)
}
}
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
// TestProjectServiceUpdateSanitizesReservedMainVariant mirrors the create case for
// renaming an existing (non-main) variant to "main".
func TestProjectServiceUpdateSanitizesReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
created, err := service.Create("tester", &CreateProjectRequest{
created, _, err := service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "Lenovo",
})
@@ -40,11 +50,41 @@ func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
}
mainName := "main"
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
updated, warning, err := service.Update(created.UUID, "tester", &UpdateProjectRequest{
Variant: &mainName,
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
if err != nil {
t.Fatalf("update project: %v", err)
}
if warning == "" {
t.Fatalf("expected a warning about the reserved variant name")
}
if updated.Variant == "" || normalizeProjectVariant(updated.Variant) == "main" {
t.Fatalf("expected variant to be converted away from 'main', got %q", updated.Variant)
}
}
// TestSanitizeProjectVariantName_InvalidChars verifies disallowed characters are
// stripped/replaced rather than rejected outright.
func TestSanitizeProjectVariantName_InvalidChars(t *testing.T) {
sanitized, warning := sanitizeProjectVariantName("Лендинг сервер!")
if warning == "" {
t.Fatalf("expected a warning for invalid characters")
}
if !projectCodeRe.MatchString(sanitized) {
t.Fatalf("sanitized variant %q still fails projectCodeRe", sanitized)
}
}
// TestSanitizeProjectVariantName_ValidPassesThrough ensures already-valid names are
// left untouched with no warning.
func TestSanitizeProjectVariantName_ValidPassesThrough(t *testing.T) {
sanitized, warning := sanitizeProjectVariantName("lenovo-2u")
if warning != "" {
t.Fatalf("expected no warning, got %q", warning)
}
if sanitized != "lenovo-2u" {
t.Fatalf("expected unchanged variant, got %q", sanitized)
}
}
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
projectService := services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
project, _, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
if err != nil {
t.Fatalf("create project: %v", err)
}
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
project, _, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
if _, _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
t.Fatalf("update project: %v", err)
}