Harden local config updates and error logging

This commit is contained in:
2026-02-19 18:41:45 +03:00
parent cbaeafa9c8
commit 075fc709dd
3 changed files with 175 additions and 9 deletions

View File

@@ -461,9 +461,21 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
projectUUID := localCfg.ProjectUUID
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)
if err != nil {
return nil, err
// 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
}
}
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
@@ -1001,13 +1013,17 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return fmt.Errorf("load current version before save: %w", err)
}
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
if err != nil {
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
return nil
// 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)
if err != nil {
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
return nil
}
}
}
@@ -1058,6 +1074,9 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").
First(&version).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &version, nil

View File

@@ -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 {
return &value
}