From f408cf4a96f59243a2f12e17df161d442d1661fe Mon Sep 17 00:00:00 2001 From: Fabio Bonelli Date: Fri, 4 Sep 2026 06:54:56 +0200 Subject: [PATCH] fix: do not record an event for an empty analysis patch --- CHANGELOG.md | 2 ++ catalogs_test.go | 22 ++++++++++++++++++++++ internal/handlers/catalogs.go | 4 ++++ internal/handlers/crud.go | 10 ++++++++++ internal/handlers/software.go | 12 ++++++++++++ software_test.go | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb123f..6f9d332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 made every later `PATCH` on that row answer `500`. - A `PATCH` on the analysis dropped a namespace that another writer had changed since the client read it. +- An empty `PATCH` on the analysis recorded an update event and fired + the webhooks. ### Security diff --git a/catalogs_test.go b/catalogs_test.go index bd27033..8fac2bd 100644 --- a/catalogs_test.go +++ b/catalogs_test.go @@ -1479,6 +1479,28 @@ func TestCatalogAnalysisDBChecks(t *testing.T) { assert.Contains(t, analysis, "ns-one", "ns-one namespace must survive a subsequent PATCH of a different namespace") assert.Contains(t, analysis, "ns-two", "ns-two namespace must be present after second PATCH") }) + + t.Run("PATCH empty analysis leaves the stored analysis untouched", func(t *testing.T) { + loadFixtures(t) + + body := `{"badges": {"v": 1, "score": 75}}` + code, response := patch(t, "/v1/catalogs/"+italiaID+"/analysis", "application/merge-patch+json", body) + require.Equal(t, 200, code, "body: %s", response) + + updatedAtBefore := dbValue(t, "catalogs", "updated_at", "id", italiaID) + eventsBefore := dbCount(t, "events", "entity_id", italiaID) + + code, response = patch(t, "/v1/catalogs/"+italiaID+"/analysis", "application/merge-patch+json", `{}`) + require.Equal(t, 200, code, "body: %s", response) + + var analysis map[string]any + require.NoError(t, json.NewDecoder(strings.NewReader(string(response))).Decode(&analysis)) + badges := analysis["badges"].(map[string]any) + assert.Equal(t, float64(75), badges["score"]) + + assert.Equal(t, updatedAtBefore, dbValue(t, "catalogs", "updated_at", "id", italiaID)) + assert.Equal(t, eventsBefore, dbCount(t, "events", "entity_id", italiaID)) + }) } func TestCatalogPostDBChecks(t *testing.T) { diff --git a/internal/handlers/catalogs.go b/internal/handlers/catalogs.go index ebfebd8..3bfba48 100644 --- a/internal/handlers/catalogs.go +++ b/internal/handlers/catalogs.go @@ -609,6 +609,10 @@ func (c *Catalog) PatchCatalogAnalysis(ctx *fiber.Ctx) error { return common.Error(fiber.StatusUnprocessableEntity, errMsg, err.Error()) } + if len(patch) == 0 { + return ctx.JSON(analysisOrEmpty(catalog.Analysis)) + } + merged, err := database.MergeAnalysis(writeDB(ctx, c.db), catalog, patch) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/handlers/crud.go b/internal/handlers/crud.go index 6324a54..5500ba8 100644 --- a/internal/handlers/crud.go +++ b/internal/handlers/crud.go @@ -147,6 +147,16 @@ func findOne[T any](gormdb *gorm.DB, id string, opts findOptions) (*T, error) { return &item, nil } +// analysisOrEmpty answers a as the response body, or an empty AnalysisData +// when a is nil, so a bare {} never renders as the JSON literal null. +func analysisOrEmpty(a common.AnalysisData) common.AnalysisData { + if a == nil { + return common.AnalysisData{} + } + + return a +} + // writeError maps the error of a create or update to Problem JSON: 409 // when a unique constraint or the alternativeId check failed, 500 // otherwise. The raw database error never reaches the client. diff --git a/internal/handlers/software.go b/internal/handlers/software.go index dd90a70..f6400b0 100644 --- a/internal/handlers/software.go +++ b/internal/handlers/software.go @@ -348,6 +348,18 @@ func (p *Software) PatchSoftwareAnalysis(ctx *fiber.Ctx) error { return common.Error(fiber.StatusUnprocessableEntity, errMsg, err.Error()) } + if len(patch) == 0 { + software, err := findOne[models.Software](p.db, ctx.Params("id"), findOptions{ + title: errMsg, + name: softwareEntityName, + }) + if err != nil { + return err + } + + return ctx.JSON(analysisOrEmpty(software.Analysis)) + } + software := models.Software{ID: ctx.Params("id")} merged, err := database.MergeAnalysis(writeDB(ctx, p.db), &software, patch) diff --git a/software_test.go b/software_test.go index a069c7e..ae885aa 100644 --- a/software_test.go +++ b/software_test.go @@ -1970,6 +1970,38 @@ func TestSoftwareAnalysisDBChecks(t *testing.T) { assert.Equal(t, first, storedNamespace(t, softwareID, "scanner")["t"]) }) + + t.Run("PATCH empty analysis leaves the stored analysis untouched", func(t *testing.T) { + loadFixtures(t) + + const softwareID = "59803fb7-8eec-4fe5-a354-8926009c364a" + + writeAnalysis(t, softwareID, `{"scanner":{"v":1,"score":90,"t":"2020-01-01T00:00:00Z"}}`) + + updatedAtBefore := dbValue(t, "software", "updated_at", "id", softwareID) + eventsBefore := dbCount(t, "events", "entity_id", softwareID) + + code, response := patch(t, "/v1/software/"+softwareID+"/analysis", "application/merge-patch+json", `{}`) + require.Equal(t, 200, code, "body: %s", response) + + var analysis map[string]any + require.NoError(t, json.NewDecoder(strings.NewReader(string(response))).Decode(&analysis)) + scanner := analysis["scanner"].(map[string]any) + assert.Equal(t, "2020-01-01T00:00:00Z", scanner["t"]) + + assert.Equal(t, updatedAtBefore, dbValue(t, "software", "updated_at", "id", softwareID)) + assert.Equal(t, eventsBefore, dbCount(t, "events", "entity_id", softwareID)) + }) + + t.Run("PATCH empty analysis on software with no analysis returns an empty object", func(t *testing.T) { + loadFixtures(t) + + const softwareID = "59803fb7-8eec-4fe5-a354-8926009c364a" + + code, response := patch(t, "/v1/software/"+softwareID+"/analysis", "application/merge-patch+json", `{}`) + require.Equal(t, 200, code, "body: %s", response) + assert.JSONEq(t, `{}`, string(response)) + }) } // writeAnalysis sets the analysis column of a software row directly, as