Harden local config updates and error logging
This commit is contained in:
@@ -1079,7 +1079,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,11 +1756,37 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
query := c.Request.URL.RawQuery
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
blw := &captureResponseWriter{
|
||||||
|
ResponseWriter: c.Writer,
|
||||||
|
body: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
c.Writer = blw
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
|
|
||||||
|
if status >= http.StatusBadRequest {
|
||||||
|
responseBody := strings.TrimSpace(blw.body.String())
|
||||||
|
if len(responseBody) > 2048 {
|
||||||
|
responseBody = responseBody[:2048] + "...(truncated)"
|
||||||
|
}
|
||||||
|
errText := strings.TrimSpace(c.Errors.String())
|
||||||
|
|
||||||
|
slog.Error("request failed",
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", path,
|
||||||
|
"query", query,
|
||||||
|
"status", status,
|
||||||
|
"latency", latency,
|
||||||
|
"ip", c.ClientIP(),
|
||||||
|
"errors", errText,
|
||||||
|
"response", responseBody,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("request",
|
slog.Info("request",
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"path", path,
|
||||||
@@ -1762,3 +1797,22 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureResponseWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
if len(b) > 0 {
|
||||||
|
_, _ = w.body.Write(b)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||||||
|
if s != "" {
|
||||||
|
_, _ = w.body.WriteString(s)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.WriteString(s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -461,11 +461,23 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID := localCfg.ProjectUUID
|
||||||
if req.ProjectUUID != nil {
|
if req.ProjectUUID != nil {
|
||||||
|
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
|
||||||
|
currentProjectUUID := ""
|
||||||
|
if localCfg.ProjectUUID != nil {
|
||||||
|
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
|
||||||
|
}
|
||||||
|
|
||||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
||||||
|
// This can happen for imported configs whose project is not present in local cache.
|
||||||
|
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
||||||
|
projectUUID = localCfg.ProjectUUID
|
||||||
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1001,6 +1013,9 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("load current version before save: %w", err)
|
return fmt.Errorf("load current version before save: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy/orphaned rows may have empty or stale current_version_id.
|
||||||
|
// In that case we treat update as content-changing and append a fresh version.
|
||||||
|
if currentVersion != nil {
|
||||||
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("compare revision content: %w", err)
|
return fmt.Errorf("compare revision content: %w", err)
|
||||||
@@ -1010,6 +1025,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
@@ -1058,6 +1074,9 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
|
|||||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
Order("version_no DESC").
|
Order("version_no DESC").
|
||||||
First(&version).Error; err != nil {
|
First(&version).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &version, nil
|
return &version, nil
|
||||||
|
|||||||
@@ -290,6 +290,99 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
project := &localdb.LocalProject{
|
||||||
|
UUID: "project-orphan",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Code: "TEST-ORPHAN",
|
||||||
|
Name: ptrString("Orphan Project"),
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "synced",
|
||||||
|
}
|
||||||
|
if err := local.SaveProject(project); err != nil {
|
||||||
|
t.Fatalf("save project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "cfg",
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate missing project in local cache while config still references its UUID.
|
||||||
|
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "cfg-updated",
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config with orphan project_uuid: %v", err)
|
||||||
|
}
|
||||||
|
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||||
|
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "cfg",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate corrupted/legacy versioning state:
|
||||||
|
// local configuration exists, but all version rows are gone and pointer is stale.
|
||||||
|
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
|
||||||
|
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete versions: %v", err)
|
||||||
|
}
|
||||||
|
staleID := "missing-version-id"
|
||||||
|
if err := local.DB().Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", created.UUID).
|
||||||
|
Update("current_version_id", staleID).Error; err != nil {
|
||||||
|
t.Fatalf("set stale current_version_id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "cfg-updated",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config with missing current version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.Name != "cfg-updated" {
|
||||||
|
t.Fatalf("expected updated name, got %q", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 1 {
|
||||||
|
t.Fatalf("expected 1 recreated version, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[0].VersionNo != 1 {
|
||||||
|
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
func ptrString(value string) *string {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user