diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f1975c..87fc41d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,12 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
privately and which versions get fixes.
- CI checks for the Helm chart with `helm lint` and `kubeconform`, and for
GitHub Actions workflows with `actionlint`.
+- `entityType`, `entityId`, `type` and `actor` filters on `GET /v1/events`.
+- An `extraEnv` value on the Helm chart, for environment variables
+ without a dedicated chart value.
### Changed
- JSON Patch operations on `id`, `createdAt`, `updatedAt` and
`catalogId` are refused with `422`, as they already were in a JSON
merge patch. `test` operations on those fields are still allowed.
+- Every `500` carries the same generic detail. A few answered `db error`.
+- The events model no longer has a `deleted_at` column. An existing
+ database keeps the column, unused.
### Fixed
@@ -26,10 +32,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
is now ignored there.
- The Helm chart now renders a valid `autoscaling/v2` HPA and quotes the
numeric `MAX_REQUESTS` environment value.
+- Concurrent `PATCH` requests to the `analysis` endpoint of the same software
+ or catalog no longer discard namespaces written by other scanners.
- A JSON Patch replacing the `id` of a software answered `200` but did
not save the software.
- A JSON Patch could store a malformed URL or email. The patched entity
now goes through the same validation as a `POST`.
+- A `DELETE` of a missing software answered `404` but still recorded a
+ delete event and notified the webhooks.
+- The event recorded by an analysis `PATCH` had no actor.
+- Catalogs, bundles and webhooks wrote no event to the audit trail.
+- A webhook `POST` with the url of an existing one answered `500` instead
+ of `409`.
+- The `409` for a duplicate catalog `alternativeId` now names the field.
+- An error the API did not classify answered `404` with the raw error
+ text. It is now a `500` with a generic detail.
+- Deleting a publisher or software did not delete its logs.
+- The `filter` parameter of `GET /v1/logs` was missing from the OpenAPI
+ spec, and the README named a Go version older than the one required.
+- The sources of a patched catalog were returned in a random order.
### Security
diff --git a/README.md b/README.md
index d43b3ba..590fd5f 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,8 @@
-
-
+
+
@@ -23,7 +23,8 @@
- Open Catalog API is a RESTful API for public software catalogs.
+ Open Catalog API is a RESTful API for public software
+ catalogs.
It powers national open source catalogs for public administrations:
@@ -32,7 +33,7 @@
## Requirements
-* Golang 1.25+
+* Golang 1.27+
* [PostgreSQL](https://www.postgresql.org/)
## Development
@@ -48,8 +49,8 @@ To start developing:
Docker Compose will bring up the app and PostgreSQL containers.
-Wait until the Docker logs explicitly say the API is up and you can use its
-endpoints at `http://localhost:3000/v1/`.
+Wait until the Docker logs explicitly say the API is up and you can use
+its endpoints at `http://localhost:3000/v1/`.
## Configuration
@@ -73,11 +74,12 @@ You can configure the API with environment variables:
If not set, the API will run in read only mode.
-* `ENVIRONMENT` (optional): possible values `test`, `development`, `production`.
+* `ENVIRONMENT` (optional): `test` turns the rate limiter and the event
+ purge off, for the test suite. Any other value is a normal run.
Default `production`.
-* `MAX_REQUESTS` (optional): number of requests per minute after which responses
- will be ratelimited.
+* `MAX_REQUESTS` (optional): number of requests per minute after which
+ responses will be ratelimited.
Default: no limit.
* `TRUSTED_PROXIES` (optional): comma separated list of addresses or
@@ -110,8 +112,8 @@ You can configure the API with environment variables:
## Contributing
-This project exists also thanks to your contributions! Here is a list of people
-who already contributed to this repository:
+This project exists also thanks to your contributions! Here is a list of
+people who already contributed to this repository:
=1.23.0-0"
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 0.7.0
+version: 0.8.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
diff --git a/charts/open-catalog-api/README.md b/charts/open-catalog-api/README.md
index 08d0595..432cd6e 100644
--- a/charts/open-catalog-api/README.md
+++ b/charts/open-catalog-api/README.md
@@ -1,6 +1,6 @@
# open-catalog-api
-  
+  
RESTful API for public administration Open Source catalogs
@@ -15,6 +15,7 @@ RESTful API for public administration Open Source catalogs
| autoscaling.targetCPUUtilizationPercentage | int | `80` | |
| databaseDSN | string | `""` | Database connection string, e.g. "host= port=5432 dbname= user= password= sslmode=require". |
| deploymentAnnotations | object | `{}` | |
+| extraEnv | list | `[]` | Additional environment variables for the API container, as Kubernetes EnvVar objects, e.g. TRUSTED_PROXIES or EVENT_RETENTION_DAYS. |
| extraVolumeMounts | list | `[]` | |
| extraVolumes | list | `[]` | |
| fullnameOverride | string | `""` | |
diff --git a/charts/open-catalog-api/templates/deployment.yaml b/charts/open-catalog-api/templates/deployment.yaml
index 34f81df..3edf601 100644
--- a/charts/open-catalog-api/templates/deployment.yaml
+++ b/charts/open-catalog-api/templates/deployment.yaml
@@ -58,6 +58,9 @@ spec:
secretKeyRef:
name: {{ default (include "open-catalog-api.fullname" .) .Values.useExistingSecret }}
key: pasetoKey
+ {{- with .Values.extraEnv }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
livenessProbe:
httpGet:
path: /livez
diff --git a/charts/open-catalog-api/values.yaml b/charts/open-catalog-api/values.yaml
index b22716c..8ae4208 100644
--- a/charts/open-catalog-api/values.yaml
+++ b/charts/open-catalog-api/values.yaml
@@ -116,3 +116,6 @@ serviceMonitor:
extraVolumes: []
extraVolumeMounts: []
+
+# -- (list) Additional environment variables for the API container, as Kubernetes EnvVar objects, e.g. TRUSTED_PROXIES or EVENT_RETENTION_DAYS.
+extraEnv: []
diff --git a/events_actor_test.go b/events_actor_test.go
index ba37b19..57010cd 100644
--- a/events_actor_test.go
+++ b/events_actor_test.go
@@ -67,6 +67,81 @@ func TestEventActor(t *testing.T) {
expectedCode: 200,
expectedActor: "editor",
},
+ {
+ description: "POST catalog",
+ method: http.MethodPost,
+ path: "/v1/catalogs",
+ body: `{"name":"actor test catalog","sources":[{"url":"https://actor-test.example.org/catalog"}]}`,
+ subject: "curator",
+ expectedCode: 200,
+ expectedActor: "curator",
+ },
+ {
+ description: "PATCH catalog",
+ method: http.MethodPatch,
+ path: catalogPath + italiaID,
+ body: `{"name":"actor test patched catalog"}`,
+ subject: "curator",
+ entityID: italiaID,
+ expectedCode: 200,
+ expectedActor: "curator",
+ },
+ {
+ description: "POST bundle",
+ method: http.MethodPost,
+ path: "/v1/bundles",
+ body: `{"name":"actor test bundle","softwareIds":["` + swissSoftwareID + `"]}`,
+ subject: "curator",
+ expectedCode: 200,
+ expectedActor: "curator",
+ },
+ {
+ description: "PATCH bundle",
+ method: http.MethodPatch,
+ path: "/v1/bundles/" + fixtureBundleID,
+ body: `{"name":"actor test patched bundle"}`,
+ subject: "curator",
+ entityID: fixtureBundleID,
+ expectedCode: 200,
+ expectedActor: "curator",
+ },
+ {
+ description: "DELETE bundle",
+ method: http.MethodDelete,
+ path: "/v1/bundles/" + fixtureBundleID,
+ subject: "curator",
+ entityID: fixtureBundleID,
+ expectedCode: 204,
+ expectedActor: "curator",
+ },
+ {
+ description: "POST webhook",
+ method: http.MethodPost,
+ path: "/v1/software/webhooks",
+ body: `{"url":"https://actor-test.example.org/hook"}`,
+ subject: "operator",
+ expectedCode: 200,
+ expectedActor: "operator",
+ },
+ {
+ description: "DELETE webhook",
+ method: http.MethodDelete,
+ path: "/v1/webhooks/24bc1b5d-fe81-47be-9d55-910f820bdd04",
+ subject: "operator",
+ entityID: "24bc1b5d-fe81-47be-9d55-910f820bdd04",
+ expectedCode: 204,
+ expectedActor: "operator",
+ },
+ {
+ description: "PATCH software analysis",
+ method: http.MethodPatch,
+ path: softwarePath + swissSoftwareID + "/analysis",
+ body: `{"actor-test":{"v":1}}`,
+ subject: "scanner",
+ entityID: swissSoftwareID,
+ expectedCode: 200,
+ expectedActor: "scanner",
+ },
{
description: "PATCH software",
method: http.MethodPatch,
diff --git a/events_test.go b/events_test.go
index 52acd6d..f3f09c4 100644
--- a/events_test.go
+++ b/events_test.go
@@ -170,6 +170,88 @@ func TestEventsEndpoints(t *testing.T) {
},
// GET /events/:id
+ {
+ description: "GET events of one entity type",
+ query: "GET /v1/events?entityType=publishers",
+ headers: authHeaders,
+ expectedCode: 200,
+ expectedContentType: "application/json",
+ validateFunc: func(t *testing.T, response map[string]any) {
+ data := assertListResponse(t, response)
+ assert.Len(t, data, 3)
+
+ for _, event := range data {
+ assert.Equal(t, "publishers", event["entityType"])
+ }
+ },
+ },
+ {
+ description: "GET events of one entity",
+ query: "GET /v1/events?entityId=c353756e-8597-4e46-a99b-7da2e141603b",
+ headers: authHeaders,
+ expectedCode: 200,
+ expectedContentType: "application/json",
+ validateFunc: func(t *testing.T, response map[string]any) {
+ data := assertListResponse(t, response)
+ assert.Len(t, data, 3)
+
+ for _, event := range data {
+ assert.Equal(t, "c353756e-8597-4e46-a99b-7da2e141603b", event["entityId"])
+ }
+ },
+ },
+ {
+ description: "GET events of one type",
+ query: "GET /v1/events?type=update",
+ headers: authHeaders,
+ expectedCode: 200,
+ expectedContentType: "application/json",
+ validateFunc: func(t *testing.T, response map[string]any) {
+ data := assertListResponse(t, response)
+ assert.Len(t, data, 3)
+
+ for _, event := range data {
+ assert.Equal(t, "update", event["type"])
+ }
+ },
+ },
+ {
+ description: "GET events of one actor",
+ query: "GET /v1/events?actor=crawler",
+ headers: authHeaders,
+ expectedCode: 200,
+ expectedContentType: "application/json",
+ validateFunc: func(t *testing.T, response map[string]any) {
+ data := assertListResponse(t, response)
+ assert.Len(t, data, 4)
+
+ for _, event := range data {
+ assert.Equal(t, "crawler", event["actor"])
+ }
+ },
+ },
+ {
+ description: "GET events with two filters",
+ query: "GET /v1/events?actor=editor&entityType=publishers",
+ headers: authHeaders,
+ expectedCode: 200,
+ expectedContentType: "application/json",
+ validateFunc: func(t *testing.T, response map[string]any) {
+ data := assertListResponse(t, response)
+ assert.Len(t, data, 2)
+
+ // The filters survive in the pagination links.
+ assertPaginationLinks(t, response, nil, nil)
+ },
+ },
+ {
+ description: "GET events with an unknown type",
+ query: "GET /v1/events?type=rename",
+ headers: authHeaders,
+ expectedCode: 422,
+ expectedBody: `{"title":"can't get Events","detail":"type must be one of create, update, delete","status":422}`,
+ expectedContentType: "application/problem+json",
+ },
{
description: "GET event without a token",
query: "GET /v1/events/" + eventWithActorID,
diff --git a/internal/common/dberr.go b/internal/common/dberr.go
index 1d4427a..ca29dcd 100644
--- a/internal/common/dberr.go
+++ b/internal/common/dberr.go
@@ -6,25 +6,35 @@ import (
"github.com/jackc/pgx/v5/pgconn"
)
+const (
+ fieldAlternativeID = "alternativeId"
+ fieldURL = "url"
+)
+
// pgConstraintToAPI maps PostgreSQL unique index names to API field names.
// GORM generates these as idx__ for uniqueIndex fields.
var pgConstraintToAPI = map[string]string{ //nolint:gochecknoglobals
"idx_bundles_name": "name",
+ "idx_catalogs_alternative_id": fieldAlternativeID,
"idx_publishers_description": "description",
- "idx_publishers_alternative_id": "alternativeId",
+ "idx_publishers_alternative_id": fieldAlternativeID,
"idx_publishers_code_hosting_url": "codeHosting.url",
- "idx_software_urls_url": "url",
+ "idx_software_urls_url": fieldURL,
+ "idx_webhook_url": fieldURL,
}
// sqliteColToAPI maps SQLite "table.column" identifiers to API field names.
// SQLite unique constraint errors always have the format:
-// "UNIQUE constraint failed: table.column".
+// "UNIQUE constraint failed: table.column". A composite index lists its
+// columns one after the other, and the first one names the field.
var sqliteColToAPI = map[string]string{ //nolint:gochecknoglobals
"bundles.name": "name",
+ "catalogs.alternative_id": fieldAlternativeID,
"publishers.description": "description",
- "publishers.alternative_id": "alternativeId",
+ "publishers.alternative_id": fieldAlternativeID,
"publishers_code_hosting.url": "codeHosting.url",
- "software_urls.url": "url",
+ "software_urls.url": fieldURL,
+ "webhooks.url": fieldURL,
}
// DuplicateField reports whether err is a unique constraint violation.
diff --git a/internal/common/dberr_sqlite.go b/internal/common/dberr_sqlite.go
index 2467280..ca817ce 100644
--- a/internal/common/dberr_sqlite.go
+++ b/internal/common/dberr_sqlite.go
@@ -21,8 +21,9 @@ func duplicateFieldSQLite(err error) *string {
}
msg := sqliteErr.Error()
- if _, tableCol, ok := strings.Cut(msg, "UNIQUE constraint failed: "); ok {
- field := sqliteColToAPI[tableCol]
+ if _, tableCols, ok := strings.Cut(msg, "UNIQUE constraint failed: "); ok {
+ firstCol, _, _ := strings.Cut(tableCols, ",")
+ field := sqliteColToAPI[firstCol]
return &field
}
diff --git a/internal/common/errors.go b/internal/common/errors.go
index 69778c8..46d58ad 100644
--- a/internal/common/errors.go
+++ b/internal/common/errors.go
@@ -2,6 +2,7 @@ package common
import (
"errors"
+ "log"
"net/http"
"github.com/gofiber/fiber/v2"
@@ -42,11 +43,20 @@ func CustomErrorHandler(ctx *fiber.Ctx, err error) error {
if problemJSON == nil {
//nolint:errorlint
- switch e := err.(type) {
+ switch typed := err.(type) {
case ProblemJSONError:
- problemJSON = &e
+ problemJSON = &typed
default:
- problemJSON = &ProblemJSONError{Status: fiber.StatusNotFound, Title: fiber.ErrNotFound.Message, Detail: e.Error()}
+ // Nothing classified this error, so it is a failure of the
+ // server, and its text can carry the schema or the failing
+ // statement: it goes to the log, the client gets a generic 500.
+ log.Printf("unhandled error on %s %s: %s", ctx.Method(), ctx.Path(), typed)
+
+ problemJSON = &ProblemJSONError{
+ Status: fiber.StatusInternalServerError,
+ Title: fiber.ErrInternalServerError.Message,
+ Detail: fiber.ErrInternalServerError.Message,
+ }
}
}
diff --git a/internal/common/errors_test.go b/internal/common/errors_test.go
index 93bdda0..6d77235 100644
--- a/internal/common/errors_test.go
+++ b/internal/common/errors_test.go
@@ -2,6 +2,8 @@ package common
import (
"encoding/json"
+ "errors"
+ "io"
"net/http"
"testing"
@@ -73,3 +75,32 @@ func TestCustomErrorHandler_AuthError(t *testing.T) {
assert.Equal(t, fiber.StatusUnauthorized, res.StatusCode)
}
+
+// An error nothing classified is a server failure, and its text can
+// carry the schema or the failing statement, so it stays in the log.
+func TestCustomErrorHandler_UnknownError(t *testing.T) {
+ app := newTestApp()
+ app.Get("/unknown", func(ctx *fiber.Ctx) error {
+ return errors.New(`pq: relation "secret_table" does not exist`)
+ })
+
+ req, err := http.NewRequest(http.MethodGet, "/unknown", nil)
+ require.NoError(t, err)
+ req.Host = "localhost"
+
+ res, err := app.Test(req, -1)
+ require.NoError(t, err)
+
+ assert.Equal(t, fiber.StatusInternalServerError, res.StatusCode)
+
+ raw, err := io.ReadAll(res.Body)
+ require.NoError(t, err)
+ assert.NotContains(t, string(raw), "secret_table")
+
+ var body ProblemJSONError
+ require.NoError(t, json.Unmarshal(raw, &body))
+
+ assert.Equal(t, fiber.ErrInternalServerError.Message, body.Title)
+ assert.Equal(t, fiber.ErrInternalServerError.Message, body.Detail)
+ assert.Equal(t, fiber.StatusInternalServerError, body.Status)
+}
diff --git a/internal/common/validator.go b/internal/common/validator.go
index e7ed991..7a9bc2a 100644
--- a/internal/common/validator.go
+++ b/internal/common/validator.go
@@ -7,6 +7,7 @@ import (
"net/url"
"reflect"
"strings"
+ "sync"
"time"
"github.com/go-playground/validator/v10"
@@ -116,7 +117,14 @@ func validateWebhookURL(fl validator.FieldLevel) bool {
return true
}
-func ValidateStruct(validateStruct any) []ValidationError {
+// requestValidator is built once: a Validate instance caches the parsed
+// tags of every struct it sees and is safe for concurrent use, so a new
+// one per request parsed the same tags again on every call.
+//
+//nolint:gochecknoglobals // one shared instance is the point
+var requestValidator = sync.OnceValue(newRequestValidator)
+
+func newRequestValidator() *validator.Validate {
validate := validator.New()
// https://github.com/go-playground/validator/issues/258#issuecomment-257281334
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
@@ -127,9 +135,13 @@ func ValidateStruct(validateStruct any) []ValidationError {
_ = validate.RegisterValidation("webhook_url", validateWebhookURL)
validate.RegisterStructValidation(validateGitHubWebhook, Webhook{})
+ return validate
+}
+
+func ValidateStruct(validateStruct any) []ValidationError {
var validationErrors []ValidationError
- if err := validate.Struct(validateStruct); err != nil {
+ if err := requestValidator().Struct(validateStruct); err != nil {
ve, ok := errors.AsType[validator.ValidationErrors](err)
if !ok {
return nil
diff --git a/internal/common/validator_test.go b/internal/common/validator_test.go
index 6d1436f..48e6353 100644
--- a/internal/common/validator_test.go
+++ b/internal/common/validator_test.go
@@ -160,3 +160,9 @@ func TestValidateCodeHostingURL(t *testing.T) {
})
}
}
+
+func TestValidateStructSharesOneValidator(t *testing.T) {
+ t.Parallel()
+
+ assert.Same(t, requestValidator(), requestValidator())
+}
diff --git a/internal/database/analysis.go b/internal/database/analysis.go
new file mode 100644
index 0000000..f208c21
--- /dev/null
+++ b/internal/database/analysis.go
@@ -0,0 +1,100 @@
+package database
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/publiccodeyml/open-catalog-api/internal/common"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+var errUnsupportedAnalysisDialect = errors.New("unsupported database dialect for analysis merge")
+
+// MergeAnalysis atomically replaces the namespaces in patch while preserving
+// every namespace that is already stored but absent from patch.
+func MergeAnalysis(
+ gormdb *gorm.DB,
+ model any,
+ patch common.AnalysisData,
+ query string,
+ args ...any,
+) (common.AnalysisData, error) {
+ if patch == nil {
+ patch = common.AnalysisData{}
+ }
+
+ expression, err := analysisMergeExpression(gormdb.Name(), patch)
+ if err != nil {
+ return nil, err
+ }
+
+ var stored struct {
+ Analysis common.AnalysisData `gorm:"type:jsonb"`
+ }
+
+ err = gormdb.Transaction(func(transaction *gorm.DB) error {
+ result := transaction.Model(model).Where(query, args...).Update("analysis", expression)
+ if result.Error != nil {
+ return fmt.Errorf("update analysis: %w", result.Error)
+ }
+
+ if result.RowsAffected == 0 {
+ return gorm.ErrRecordNotFound
+ }
+
+ if err := transaction.Model(model).
+ Select("analysis").
+ Where(query, args...).
+ Take(&stored).Error; err != nil {
+ return fmt.Errorf("read merged analysis: %w", err)
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("merge analysis: %w", err)
+ }
+
+ if stored.Analysis == nil {
+ return common.AnalysisData{}, nil
+ }
+
+ return stored.Analysis, nil
+}
+
+func analysisMergeExpression(dialect string, patch common.AnalysisData) (clause.Expr, error) {
+ patchJSON, err := json.Marshal(patch)
+ if err != nil {
+ return clause.Expr{}, fmt.Errorf("marshal analysis patch: %w", err)
+ }
+
+ switch dialect {
+ case Postgres:
+ return gorm.Expr(
+ "COALESCE(analysis, '{}'::jsonb) || CAST(? AS jsonb)",
+ string(patchJSON),
+ ), nil
+ case SQLite:
+ // json_patch cannot be used here: its recursive merge would treat a
+ // null inside a namespace as a deletion. Rebuild only the top-level
+ // object so namespace values remain opaque and are replaced whole.
+ return gorm.Expr(
+ `(
+ SELECT json_group_object(merged.key, json(merged.value))
+ FROM (
+ SELECT key, value
+ FROM json_each(COALESCE(analysis, '{}'))
+ WHERE key NOT IN (SELECT key FROM json_each(json(?)))
+ UNION ALL
+ SELECT key, value FROM json_each(json(?))
+ ) AS merged
+ )`,
+ string(patchJSON),
+ string(patchJSON),
+ ), nil
+ default:
+ return clause.Expr{}, fmt.Errorf("%w: %s", errUnsupportedAnalysisDialect, dialect)
+ }
+}
diff --git a/internal/database/analysis_test.go b/internal/database/analysis_test.go
new file mode 100644
index 0000000..15783ab
--- /dev/null
+++ b/internal/database/analysis_test.go
@@ -0,0 +1,132 @@
+package database
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/gofiber/fiber/v2/utils"
+ "github.com/publiccodeyml/open-catalog-api/internal/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+type analysisMergeRecord struct {
+ ID string `gorm:"primaryKey"`
+ Analysis common.AnalysisData `gorm:"type:jsonb"`
+ UpdatedAt time.Time
+}
+
+func (analysisMergeRecord) TableName() string {
+ return "analysis_merge_test_records"
+}
+
+func TestMergeAnalysisConcurrentNamespaces(t *testing.T) {
+ gormdb := openAnalysisTestDatabase(t)
+ recordID := utils.UUIDv4()
+ record := analysisMergeRecord{ID: recordID}
+ require.NoError(t, gormdb.Create(&record).Error)
+ t.Cleanup(func() {
+ require.NoError(t, gormdb.Delete(&analysisMergeRecord{}, "id = ?", recordID).Error)
+ })
+
+ patches := []common.AnalysisData{
+ {"scanner-one": json.RawMessage(`{"v":1,"score":90}`)},
+ {"scanner-two": json.RawMessage(`{"v":2,"grade":"A"}`)},
+ }
+
+ start := make(chan struct{})
+ errorsChannel := make(chan error, len(patches))
+
+ for _, patch := range patches {
+ go func() {
+ <-start
+
+ target := analysisMergeRecord{ID: recordID}
+ _, err := MergeAnalysis(gormdb, &target, patch, "id = ?", recordID)
+ errorsChannel <- err
+ }()
+ }
+
+ close(start)
+ for range patches {
+ require.NoError(t, <-errorsChannel)
+ }
+
+ var stored analysisMergeRecord
+ require.NoError(t, gormdb.First(&stored, "id = ?", recordID).Error)
+ require.Len(t, stored.Analysis, 2)
+ assert.JSONEq(t, `{"v":1,"score":90}`, string(stored.Analysis["scanner-one"]))
+ assert.JSONEq(t, `{"v":2,"grade":"A"}`, string(stored.Analysis["scanner-two"]))
+}
+
+func TestMergeAnalysisReplacesWholeNamespace(t *testing.T) {
+ gormdb := openAnalysisTestDatabase(t)
+ recordID := utils.UUIDv4()
+ record := analysisMergeRecord{
+ ID: recordID,
+ Analysis: common.AnalysisData{
+ "scanner": json.RawMessage(`{"v":1,"stale":true}`),
+ "other": json.RawMessage(`{"v":1,"keep":true}`),
+ },
+ }
+ require.NoError(t, gormdb.Create(&record).Error)
+ t.Cleanup(func() {
+ require.NoError(t, gormdb.Delete(&analysisMergeRecord{}, "id = ?", recordID).Error)
+ })
+
+ target := analysisMergeRecord{ID: recordID}
+ merged, err := MergeAnalysis(
+ gormdb,
+ &target,
+ common.AnalysisData{"scanner": json.RawMessage(`{"v":2,"fresh":true,"nullable":null}`)},
+ "id = ?",
+ recordID,
+ )
+ require.NoError(t, err)
+ require.Len(t, merged, 2)
+ assert.JSONEq(t, `{"v":2,"fresh":true,"nullable":null}`, string(merged["scanner"]))
+ assert.JSONEq(t, `{"v":1,"keep":true}`, string(merged["other"]))
+}
+
+func openAnalysisTestDatabase(t *testing.T) *gorm.DB {
+ t.Helper()
+
+ dsn := os.Getenv("DATABASE_DSN")
+ dialect := SQLite
+
+ if dsn != "" {
+ var err error
+
+ dialect, err = Dialect(dsn)
+ require.NoError(t, err)
+ }
+
+ var (
+ gormdb *gorm.DB
+ err error
+ )
+
+ if dialect == Postgres {
+ gormdb, err = gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
+ } else {
+ dsn = "file:" + filepath.Join(t.TempDir(), "analysis.db") + "?_journal_mode=WAL&_busy_timeout=5000"
+ gormdb, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ }
+ require.NoError(t, err)
+ require.NoError(t, gormdb.AutoMigrate(&analysisMergeRecord{}))
+
+ sqldb, err := gormdb.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqldb.Close())
+ })
+
+ return gormdb
+}
diff --git a/internal/database/events.go b/internal/database/events.go
index 3efa068..a660c34 100644
--- a/internal/database/events.go
+++ b/internal/database/events.go
@@ -11,13 +11,11 @@ import (
)
// PurgeEvents deletes the events created more than olderThan ago and
-// returns how many rows went away. The delete is Unscoped: an entry past
-// the retention window leaves the audit trail for good, a soft delete
-// would keep the row in the table.
+// returns how many rows went away.
func PurgeEvents(gormdb *gorm.DB, olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan)
- result := gormdb.Unscoped().Where("created_at < ?", cutoff).Delete(&models.Event{})
+ result := gormdb.Where("created_at < ?", cutoff).Delete(&models.Event{})
if result.Error != nil {
return 0, fmt.Errorf("can't purge the events older than %s: %w", olderThan, result.Error)
}
diff --git a/internal/database/events_test.go b/internal/database/events_test.go
index 444fd48..cf66088 100644
--- a/internal/database/events_test.go
+++ b/internal/database/events_test.go
@@ -39,14 +39,14 @@ func createEvent(t *testing.T, gormdb *gorm.DB, age time.Duration) string {
return event.ID
}
-// countEvents counts unscoped, so a soft deleted row still counts and a
-// purge passes only when the row is gone.
+// countEvents counts the event rows matching where, all of them when
+// where is empty.
func countEvents(t *testing.T, gormdb *gorm.DB, where string, args ...any) int64 {
t.Helper()
var count int64
- query := gormdb.Unscoped().Model(&models.Event{})
+ query := gormdb.Model(&models.Event{})
if where != "" {
query = query.Where(where, args...)
}
@@ -79,20 +79,6 @@ func TestPurgeEvents(t *testing.T) {
assert.False(t, eventExists(t, gormdb, expired))
}
-func TestPurgeEventsDeletesTheSoftDeletedRows(t *testing.T) {
- gormdb := newEventDatabase(t)
-
- expired := createEvent(t, gormdb, 40*day)
- require.NoError(t, gormdb.Delete(&models.Event{}, "id = ?", expired).Error)
- require.Equal(t, int64(1), countEvents(t, gormdb, ""))
-
- purged, err := PurgeEvents(gormdb, 30*day)
- require.NoError(t, err)
-
- assert.Equal(t, int64(1), purged)
- assert.Equal(t, int64(0), countEvents(t, gormdb, ""))
-}
-
func TestStartEventPurgeDeletesOnStartup(t *testing.T) {
gormdb := newEventDatabase(t)
diff --git a/internal/handlers/bundles.go b/internal/handlers/bundles.go
index d09c29c..da58550 100644
--- a/internal/handlers/bundles.go
+++ b/internal/handlers/bundles.go
@@ -68,7 +68,9 @@ func (b *Bundle) PostBundle(ctx *fiber.Ctx) error {
}
// Omit("Software.*") links the existing rows without upserting them.
- if err := b.db.Omit("Software.*").Create(bundle).Error; err != nil {
+ if err := models.Transaction(writeDB(ctx, b.db), func(tran *gorm.DB) error {
+ return tran.Omit("Software.*").Create(bundle).Error
+ }); err != nil {
return writeError(err, errMsg)
}
@@ -106,14 +108,16 @@ func (b *Bundle) PatchBundle(ctx *fiber.Ctx) error {
updatedBundle.Software = nil
- err = b.db.Transaction(func(tran *gorm.DB) error {
+ err = models.Transaction(writeDB(ctx, b.db), func(tran *gorm.DB) error {
if err := updateColumns(tran, &updatedBundle, "Name", "Description", "Active"); err != nil {
return err
}
// Omit("Software.*") swaps the join rows without upserting the
- // software rows themselves.
- return tran.Model(&updatedBundle).Omit("Software.*").
+ // software rows themselves. The swap saves the bundle again, and
+ // updateColumns has already recorded that update, so the hooks
+ // are skipped here or the same change would emit two events.
+ return tran.Session(&gorm.Session{SkipHooks: true}).Model(&updatedBundle).Omit("Software.*").
Association("Software").Replace(software)
})
if err != nil {
@@ -135,7 +139,9 @@ func (b *Bundle) DeleteBundle(ctx *fiber.Ctx) error {
}
// Select("Software") also removes the bundles_software join rows.
- if err := b.db.Select(softwareAssociation).Delete(bundle).Error; err != nil {
+ if err := models.Transaction(writeDB(ctx, b.db), func(tran *gorm.DB) error {
+ return tran.Select(softwareAssociation).Delete(bundle).Error
+ }); err != nil {
return common.InternalServerError(errMsg)
}
diff --git a/internal/handlers/catalogs.go b/internal/handlers/catalogs.go
index 72cab7d..c2a693e 100644
--- a/internal/handlers/catalogs.go
+++ b/internal/handlers/catalogs.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "cmp"
"encoding/json"
"errors"
"net/url"
@@ -10,6 +11,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/utils"
"github.com/publiccodeyml/open-catalog-api/internal/common"
+ "github.com/publiccodeyml/open-catalog-api/internal/database"
"github.com/publiccodeyml/open-catalog-api/internal/handlers/general"
"github.com/publiccodeyml/open-catalog-api/internal/models"
"gorm.io/gorm"
@@ -83,13 +85,9 @@ func (c *Catalog) GetCatalogs(ctx *fiber.Ctx) error {
func (c *Catalog) GetCatalog(ctx *fiber.Ctx) error {
id, _ := url.PathUnescape(ctx.Params("id"))
- catalog, err := resolveCatalog(c.db, id, "Sources")
+ catalog, err := resolveCatalogOr404(c.db, id, "can't get Catalog", "Sources")
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, "can't get Catalog", "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, "can't get Catalog", fiber.ErrInternalServerError.Message)
+ return err
}
if catalog == nil {
@@ -137,7 +135,9 @@ func (c *Catalog) PostCatalog(ctx *fiber.Ctx) error {
Sources: sources,
}
- if err := c.db.Create(catalog).Error; err != nil {
+ if err := models.Transaction(writeDB(ctx, c.db), func(tran *gorm.DB) error {
+ return tran.Create(catalog).Error
+ }); err != nil {
return writeError(err, errMsg)
}
@@ -150,13 +150,9 @@ func (c *Catalog) PatchCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
catalogID, _ := url.PathUnescape(ctx.Params("id"))
- resolved, err := resolveCatalog(c.db, catalogID, "Sources")
+ resolved, err := resolveCatalogOr404(c.db, catalogID, errMsg, "Sources")
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message)
+ return err
}
if resolved == nil {
@@ -200,7 +196,7 @@ func (c *Catalog) PatchCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
return common.Error(fiber.StatusUnprocessableEntity, errMsg, "sources must not be empty")
}
- if err := c.db.Transaction(func(tran *gorm.DB) error {
+ if err := models.Transaction(writeDB(ctx, c.db), func(tran *gorm.DB) error {
sources, err := syncSources(tran, catalog, sourcesInput)
if err != nil {
return err
@@ -228,18 +224,14 @@ func (c *Catalog) PatchCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
// Returns 409 if the catalog still has associated publishers or software.
// On the root (∅) the count of attached resources is taken from rows with
// catalog_id IS NULL, since root resources are never tied to the row's UUID.
-func (c *Catalog) DeleteCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
+func (c *Catalog) DeleteCatalog(ctx *fiber.Ctx) error {
const errMsg = "can't delete Catalog"
catalogID, _ := url.PathUnescape(ctx.Params("id"))
- resolved, err := resolveCatalog(c.db, catalogID)
+ resolved, err := resolveCatalogOr404(c.db, catalogID, errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message)
+ return err
}
if resolved == nil {
@@ -250,7 +242,7 @@ func (c *Catalog) DeleteCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
var conflictErr error
- if err := c.db.Transaction(func(tran *gorm.DB) error {
+ if err := models.Transaction(writeDB(ctx, c.db), func(tran *gorm.DB) error {
var publisherCount, softwareCount int64
pubScope := tran.Model(&models.Publisher{}).Scopes(catalogScope(&catalog))
@@ -273,9 +265,9 @@ func (c *Catalog) DeleteCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
return err
}
- return tran.Where("id = ?", catalog.ID).Delete(&models.Catalog{}).Error
+ return tran.Delete(&catalog).Error
}); err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ return common.InternalServerError(errMsg)
}
if conflictErr != nil {
@@ -289,13 +281,9 @@ func (c *Catalog) DeleteCatalog(ctx *fiber.Ctx) error { //nolint:cyclop
func (c *Catalog) GetCatalogPublishers(ctx *fiber.Ctx) error {
id := ctx.Params("id")
- catalog, err := resolveCatalog(c.db, id)
+ catalog, err := resolveCatalogOr404(c.db, id, "can't get Publishers")
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, "can't get Publishers", "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, "can't get Publishers", fiber.ErrInternalServerError.Message)
+ return err
}
stmt := c.db.Preload(codeHostingAssociation).Scopes(catalogScope(catalog))
@@ -311,13 +299,9 @@ func (c *Catalog) GetCatalogPublishers(ctx *fiber.Ctx) error {
func (c *Catalog) PostCatalogPublisher(ctx *fiber.Ctx) error {
const errMsg = "can't create Publisher"
- catalog, err := resolveCatalog(c.db, ctx.Params("id"))
+ catalog, err := resolveCatalogOr404(c.db, ctx.Params("id"), errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message)
+ return err
}
return createPublisher(ctx, writeDB(ctx, c.db), catalogOwnerID(catalog))
@@ -327,13 +311,9 @@ func (c *Catalog) PostCatalogPublisher(ctx *fiber.Ctx) error {
func (c *Catalog) PatchCatalogPublisher(ctx *fiber.Ctx) error {
const errMsg = "can't update Publisher"
- catalog, err := resolveCatalog(c.db, ctx.Params("id"))
+ catalog, err := resolveCatalogOr404(c.db, ctx.Params("id"), errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message)
+ return err
}
found, err := findOne[models.Publisher](c.db, ctx.Params("publisherId"), findOptions{
@@ -358,13 +338,9 @@ func (c *Catalog) PatchCatalogPublisher(ctx *fiber.Ctx) error {
func (c *Catalog) PostCatalogSoftware(ctx *fiber.Ctx) error {
const errMsg = "can't create Software"
- catalog, err := resolveCatalog(c.db, ctx.Params("id"))
+ catalog, err := resolveCatalogOr404(c.db, ctx.Params("id"), errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message)
+ return err
}
return createSoftware(ctx, writeDB(ctx, c.db), catalogOwnerID(catalog))
@@ -374,13 +350,9 @@ func (c *Catalog) PostCatalogSoftware(ctx *fiber.Ctx) error {
func (c *Catalog) PatchCatalogSoftware(ctx *fiber.Ctx) error {
const errMsg = "can't update Software"
- catalog, err := resolveCatalog(c.db, ctx.Params("id"))
+ catalog, err := resolveCatalogOr404(c.db, ctx.Params("id"), errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message)
+ return err
}
software := models.Software{}
@@ -404,13 +376,9 @@ func (c *Catalog) PatchCatalogSoftware(ctx *fiber.Ctx) error {
func (c *Catalog) GetCatalogSoftware(ctx *fiber.Ctx) error {
id := ctx.Params("id")
- catalog, err := resolveCatalog(c.db, id)
+ catalog, err := resolveCatalogOr404(c.db, id, "can't get Software")
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, "can't get Software", "Catalog was not found")
- }
-
- return common.Error(fiber.StatusInternalServerError, "can't get Software", fiber.ErrInternalServerError.Message)
+ return err
}
stmt, err := general.Clauses(ctx, c.db.Preload("Aliases").Scopes(catalogScope(catalog)), "", "")
@@ -539,6 +507,11 @@ func syncSources( //nolint:cyclop,funlen
ret = append(ret, src)
}
+ // urlMap iterates in a random order, the response must not.
+ slices.SortFunc(ret, func(a, b models.CatalogSource) int {
+ return cmp.Compare(a.URL, b.URL)
+ })
+
return ret, nil
}
@@ -571,19 +544,32 @@ func resolveCatalog(gormdb *gorm.DB, rawID string, preloads ...string) (*models.
return nil, dbErr
}
+// resolveCatalogOr404 is resolveCatalog for a handler: a catalog that does
+// not exist is a 404 Problem JSON error, any other failure a 500 one, both
+// ready to be returned as they are. A nil catalog with a nil error is the
+// implicit root, as in resolveCatalog.
+func resolveCatalogOr404(gormdb *gorm.DB, rawID, title string, preloads ...string) (*models.Catalog, error) {
+ catalog, err := resolveCatalog(gormdb, rawID, preloads...)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, common.Error(fiber.StatusNotFound, title, "Catalog was not found")
+ }
+
+ return nil, common.InternalServerError(title)
+ }
+
+ return catalog, nil
+}
+
// GetCatalogAnalysis returns the analysis data for the catalog with the given id.
func (c *Catalog) GetCatalogAnalysis(ctx *fiber.Ctx) error {
const errMsg = "can't get Catalog analysis"
id, _ := url.PathUnescape(ctx.Params("id"))
- catalog, err := resolveCatalog(c.db, id)
+ catalog, err := resolveCatalogOr404(c.db, id, errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.InternalServerError(errMsg)
+ return err
}
if catalog == nil {
@@ -604,13 +590,9 @@ func (c *Catalog) PatchCatalogAnalysis(ctx *fiber.Ctx) error {
id, _ := url.PathUnescape(ctx.Params("id"))
- catalog, err := resolveCatalog(c.db, id)
+ catalog, err := resolveCatalogOr404(c.db, id, errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
- }
-
- return common.InternalServerError(errMsg)
+ return err
}
if catalog == nil {
@@ -622,14 +604,19 @@ func (c *Catalog) PatchCatalogAnalysis(ctx *fiber.Ctx) error {
return common.Error(fiber.StatusUnprocessableEntity, errMsg, "invalid or malformed JSON")
}
- // The error here is one of a fixed set of validation messages prefixed
- // with the namespace the caller sent, so it can be returned as is.
- merged, err := injectTouchedAnalysis(catalog.Analysis, incoming, time.Now())
+ // Validate and timestamp only the changed namespaces. The returned map
+ // is a database patch, not a copy of the complete analysis document.
+ patch, err := timestampTouchedAnalysis(catalog.Analysis, incoming, time.Now())
if err != nil {
return common.Error(fiber.StatusUnprocessableEntity, errMsg, err.Error())
}
- if err := c.db.Model(catalog).Update("analysis", merged).Error; err != nil {
+ merged, err := database.MergeAnalysis(writeDB(ctx, c.db), catalog, patch, "id = ?", catalog.ID)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return common.Error(fiber.StatusNotFound, errMsg, "Catalog was not found")
+ }
+
return common.InternalServerError(errMsg)
}
diff --git a/internal/handlers/crud.go b/internal/handlers/crud.go
index b707cc6..6324a54 100644
--- a/internal/handlers/crud.go
+++ b/internal/handlers/crud.go
@@ -213,6 +213,14 @@ func applyPatch[T any](ctx *fiber.Ctx, current *T, opts patchOptions[T]) (T, err
return updated, nil
}
+// deleteEntityLogs soft deletes the logs attached to entity, for the
+// handlers deleting the entity itself.
+func deleteEntityLogs(tran *gorm.DB, entity models.Model) error {
+ return tran.
+ Where("entity_type = ? AND entity_id = ?", entity.TableName(), entity.UUID()).
+ Delete(&models.Log{}).Error
+}
+
// updateColumns writes the listed columns of entity and only those. A plain
// struct update skips a nil pointer, so a merge patch nulling an optional
// field would answer 200 and leave the old value in the row. Naming the
diff --git a/internal/handlers/events.go b/internal/handlers/events.go
index 88caff0..3392244 100644
--- a/internal/handlers/events.go
+++ b/internal/handlers/events.go
@@ -1,8 +1,11 @@
package handlers
import (
+ "slices"
+
"github.com/gofiber/fiber/v2"
"github.com/pilagod/gorm-cursor-paginator/v2/paginator"
+ "github.com/publiccodeyml/open-catalog-api/internal/common"
"github.com/publiccodeyml/open-catalog-api/internal/models"
"gorm.io/gorm"
)
@@ -16,11 +19,34 @@ func NewEvent(db *gorm.DB) *Event {
}
// GetEvents gets the list of all events and returns any error encountered.
+// ?entityType, ?entityId, ?type and ?actor each narrow the list to an
+// exact match, so the trail of one entity or one actor can be read on
+// its own.
func (e *Event) GetEvents(ctx *fiber.Ctx) error {
- return list[models.Event](ctx, e.db, listOptions{
- title: "can't get Events",
- order: paginator.DESC,
- })
+ const errMsg = "can't get Events"
+
+ stmt := e.db
+
+ for param, column := range map[string]string{
+ "entityType": "entity_type",
+ "entityId": "entity_id",
+ "actor": "actor",
+ } {
+ if value := ctx.Query(param); value != "" {
+ stmt = stmt.Where(map[string]any{column: value})
+ }
+ }
+
+ if eventType := ctx.Query("type"); eventType != "" {
+ known := []string{common.EventTypeCreate, common.EventTypeUpdate, common.EventTypeDelete}
+ if !slices.Contains(known, eventType) {
+ return common.Error(fiber.StatusUnprocessableEntity, errMsg, "type must be one of create, update, delete")
+ }
+
+ stmt = stmt.Where(map[string]any{"type": eventType})
+ }
+
+ return list[models.Event](ctx, stmt, listOptions{title: errMsg, order: paginator.DESC})
}
// GetEvent gets the event with the given ID and returns any error encountered.
diff --git a/internal/handlers/logs.go b/internal/handlers/logs.go
index 944c6e5..80a5041 100644
--- a/internal/handlers/logs.go
+++ b/internal/handlers/logs.go
@@ -52,7 +52,7 @@ func (p *Log) PostLog(ctx *fiber.Ctx) error {
log := models.Log{ID: utils.UUIDv4(), Message: logReq.Message}
if err := p.db.Create(&log).Error; err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ return common.InternalServerError(errMsg)
}
return ctx.JSON(&log)
@@ -78,7 +78,7 @@ func (p *Log) PatchLog(ctx *fiber.Ctx) error {
}
if err := p.db.Updates(&updatedLog).Error; err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ return common.InternalServerError(errMsg)
}
return ctx.JSON(&updatedLog)
@@ -91,7 +91,7 @@ func (p *Log) DeleteLog(ctx *fiber.Ctx) error {
result := p.db.Delete(&log, "id = ?", ctx.Params("id"))
if result.Error != nil {
- return common.Error(fiber.StatusInternalServerError, "can't delete Log", "db error")
+ return common.InternalServerError("can't delete Log")
}
if result.RowsAffected == 0 {
@@ -103,7 +103,7 @@ func (p *Log) DeleteLog(ctx *fiber.Ctx) error {
// GetSoftwareLogs gets the logs associated to a Software with the given ID and returns any error encountered.
func (p *Log) GetSoftwareLogs(ctx *fiber.Ctx) error {
- return p.getEntityLogs(ctx, &models.Software{}, "Software")
+ return p.getEntityLogs(ctx, &models.Software{}, softwareEntityName)
}
// PostCatalogLog creates a new log associated to a Catalog with the given ID and returns any error encountered.
@@ -112,17 +112,9 @@ func (p *Log) PostCatalogLog(ctx *fiber.Ctx) error {
logReq := new(common.Log)
- catalog, err := resolveCatalog(p.db, ctx.Params("id"))
+ catalog, err := resolveCatalogOr404(p.db, ctx.Params("id"), errMsg)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return common.Error(fiber.StatusNotFound, "can't create Log", "Catalog was not found")
- }
-
- return common.Error(
- fiber.StatusInternalServerError,
- "can't get Catalog",
- fiber.ErrInternalServerError.Message,
- )
+ return err
}
if err := common.ValidateRequestEntity(ctx, logReq, errMsg); err != nil {
@@ -144,7 +136,7 @@ func (p *Log) PostCatalogLog(ctx *fiber.Ctx) error {
}
if err := p.db.Create(&log).Error; err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ return common.InternalServerError(errMsg)
}
return ctx.JSON(&log)
@@ -162,7 +154,7 @@ func (p *Log) PostPublisherLog(ctx *fiber.Ctx) error {
// PostSoftwareLog creates a new log associated to a Software with the given ID and returns any error encountered.
func (p *Log) PostSoftwareLog(ctx *fiber.Ctx) error {
- return p.postEntityLog(ctx, &models.Software{}, "Software")
+ return p.postEntityLog(ctx, &models.Software{}, softwareEntityName)
}
// getEntityLogs gets the logs associated to the entity with the ID in the request path.
@@ -227,7 +219,7 @@ func (p *Log) postEntityLog(ctx *fiber.Ctx, entity models.Model, entityName stri
}
if err := p.db.Create(&log).Error; err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ return common.InternalServerError(errMsg)
}
return ctx.JSON(&log)
diff --git a/internal/handlers/publishers.go b/internal/handlers/publishers.go
index 625180f..1684b96 100644
--- a/internal/handlers/publishers.go
+++ b/internal/handlers/publishers.go
@@ -81,9 +81,13 @@ func (p *Publisher) DeletePublisher(ctx *fiber.Ctx) error {
publisher := *found
if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error {
+ if err := deleteEntityLogs(tran, publisher); err != nil {
+ return err
+ }
+
return tran.Select(codeHostingAssociation).Delete(&publisher).Error
}); err != nil {
- return common.Error(fiber.StatusInternalServerError, "can't delete Publisher", "db error")
+ return common.InternalServerError("can't delete Publisher")
}
return ctx.SendStatus(fiber.StatusNoContent)
diff --git a/internal/handlers/software.go b/internal/handlers/software.go
index 5af8cc8..930cd79 100644
--- a/internal/handlers/software.go
+++ b/internal/handlers/software.go
@@ -3,7 +3,6 @@ package handlers
import (
"encoding/json"
"errors"
- "maps"
"slices"
"sort"
"time"
@@ -11,6 +10,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/utils"
"github.com/publiccodeyml/open-catalog-api/internal/common"
+ "github.com/publiccodeyml/open-catalog-api/internal/database"
"github.com/publiccodeyml/open-catalog-api/internal/handlers/general"
"github.com/publiccodeyml/open-catalog-api/internal/models"
"gorm.io/gorm"
@@ -20,6 +20,8 @@ type Software struct {
db *gorm.DB
}
+const softwareEntityName = "Software"
+
var (
errLoadNotFound = errors.New("Software was not found")
errLoad = errors.New("error while loading Software")
@@ -87,21 +89,28 @@ func (p *Software) PatchSoftware(ctx *fiber.Ctx) error {
return updateSoftware(ctx, writeDB(ctx, p.db), software)
}
-// DeleteSoftware deletes the software with the given ID.
+// DeleteSoftware deletes the software with the given ID. The row is
+// looked up first: gorm runs the AfterDelete hook even when the delete
+// matched nothing, and the hook would record an event for a software
+// that never existed.
func (p *Software) DeleteSoftware(ctx *fiber.Ctx) error {
- var rowsAffected int64
+ const errMsg = "can't delete Software"
+
+ found, err := findOne[models.Software](p.db, ctx.Params("id"), findOptions{title: errMsg, name: softwareEntityName})
+ if err != nil {
+ return err
+ }
+
+ software := *found
if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error {
- result := tran.Select("Aliases", "Bundles").Delete(&models.Software{ID: ctx.Params("id")})
- rowsAffected = result.RowsAffected
+ if err := deleteEntityLogs(tran, software); err != nil {
+ return err
+ }
- return result.Error
+ return tran.Select("Aliases", "Bundles").Delete(&software).Error
}); err != nil {
- return common.Error(fiber.StatusInternalServerError, "can't delete Software", "db error")
- }
-
- if rowsAffected == 0 {
- return common.Error(fiber.StatusNotFound, "can't delete Software", "Software was not found")
+ return common.InternalServerError(errMsg)
}
return ctx.SendStatus(fiber.StatusNoContent)
@@ -311,7 +320,7 @@ func (p *Software) GetSoftwareAnalysis(ctx *fiber.Ctx) error {
software, err := findOne[models.Software](p.db, ctx.Params("id"), findOptions{
title: errMsg,
- name: "Software",
+ name: softwareEntityName,
})
if err != nil {
return err
@@ -331,7 +340,7 @@ func (p *Software) PatchSoftwareAnalysis(ctx *fiber.Ctx) error {
found, err := findOne[models.Software](p.db, ctx.Params("id"), findOptions{
title: errMsg,
- name: "Software",
+ name: softwareEntityName,
})
if err != nil {
return err
@@ -344,47 +353,43 @@ func (p *Software) PatchSoftwareAnalysis(ctx *fiber.Ctx) error {
return common.Error(fiber.StatusUnprocessableEntity, errMsg, "invalid or malformed JSON")
}
- // The error here is one of a fixed set of validation messages prefixed
- // with the namespace the caller sent, so it can be returned as is.
- merged, err := injectTouchedAnalysis(software.Analysis, incoming, time.Now())
+ // Validate and timestamp only the changed namespaces. The returned map
+ // is a database patch, not a copy of the complete analysis document.
+ patch, err := timestampTouchedAnalysis(software.Analysis, incoming, time.Now())
if err != nil {
return common.Error(fiber.StatusUnprocessableEntity, errMsg, err.Error())
}
- if err := p.db.Model(&software).Update("analysis", merged).Error; err != nil {
+ merged, err := database.MergeAnalysis(writeDB(ctx, p.db), &software, patch, "id = ?", software.ID)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return common.Error(fiber.StatusNotFound, errMsg, "Software was not found")
+ }
+
return common.InternalServerError(errMsg)
}
return ctx.JSON(merged)
}
-// injectTouchedAnalysis validates and injects "t" only into namespaces that
-// were added or changed by the patch. Unchanged namespaces keep their original
-// "t" value.
-func injectTouchedAnalysis(
+// timestampTouchedAnalysis returns the added or changed namespaces with a
+// fresh timestamp. Namespaces absent from updated are deliberately omitted:
+// the database merges this patch atomically with the value it currently has.
+func timestampTouchedAnalysis(
original, updated common.AnalysisData,
now time.Time,
) (common.AnalysisData, error) {
touched := make(common.AnalysisData)
- for ns, val := range updated {
- origVal, exists := original[ns]
- if !exists || string(origVal) != string(val) {
- touched[ns] = val
+ for namespace, value := range updated {
+ originalValue, exists := original[namespace]
+ if !exists || string(originalValue) != string(value) {
+ touched[namespace] = value
}
}
- injected, err := common.WithTimestamps(touched, now)
- if err != nil {
- return nil, err //nolint:wrapcheck
- }
-
- result := make(common.AnalysisData, len(original)+len(updated))
- maps.Copy(result, original)
- maps.Copy(result, updated)
- maps.Copy(result, injected)
-
- return result, nil
+ //nolint:wrapcheck // validation errors are safe API messages with their namespace prefix
+ return common.WithTimestamps(touched, now)
}
// softwareURLFilter narrows stmt to the software owning the ?url query
diff --git a/internal/handlers/webhooks.go b/internal/handlers/webhooks.go
index b68d82ef..fb85465 100644
--- a/internal/handlers/webhooks.go
+++ b/internal/handlers/webhooks.go
@@ -90,8 +90,10 @@ func (p *Webhook[T]) PostResourceWebhook(ctx *fiber.Ctx) error {
EntityType: resource.TableName(),
}
- if err := p.db.Create(&webhook).Error; err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error {
+ return tran.Create(&webhook).Error
+ }); err != nil {
+ return writeError(err, errMsg)
}
return ctx.JSON(&webhook)
@@ -127,25 +129,30 @@ func (p *Webhook[T]) PostSingleResourceWebhook(ctx *fiber.Ctx) error {
EntityType: resource.TableName(),
}
- if err := p.db.Create(&webhook).Error; err != nil {
- return common.Error(fiber.StatusInternalServerError, errMsg, "db error")
+ if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error {
+ return tran.Create(&webhook).Error
+ }); err != nil {
+ return writeError(err, errMsg)
}
return ctx.JSON(&webhook)
}
-// DeleteWebhook deletes the webhook with the given ID.
+// DeleteWebhook deletes the webhook with the given ID. The row is looked
+// up first, so that the AfterDelete hook records no event for a missing
+// id.
func (p *Webhook[T]) DeleteWebhook(ctx *fiber.Ctx) error {
- var webhook models.Webhook
+ const errMsg = "can't delete Webhook"
- result := p.db.Delete(&webhook, "id = ?", ctx.Params("id"))
-
- if result.Error != nil {
- return common.Error(fiber.StatusInternalServerError, "can't delete Webhook", "db error")
+ webhook, err := findOne[models.Webhook](p.db, ctx.Params("id"), findOptions{title: errMsg, name: "Webhook"})
+ if err != nil {
+ return err
}
- if result.RowsAffected == 0 {
- return common.Error(fiber.StatusNotFound, "can't delete Webhook", "Webhook was not found")
+ if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error {
+ return tran.Delete(webhook).Error
+ }); err != nil {
+ return common.InternalServerError(errMsg)
}
return ctx.SendStatus(fiber.StatusNoContent)
diff --git a/internal/models/hooks.go b/internal/models/hooks.go
index 3d4cca2..571710f 100644
--- a/internal/models/hooks.go
+++ b/internal/models/hooks.go
@@ -40,6 +40,38 @@ func (s Software) AfterDelete(trx *gorm.DB) error {
return emit(trx, common.EventTypeDelete, s)
}
+func (c Catalog) AfterCreate(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeCreate, c)
+}
+
+func (c Catalog) AfterUpdate(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeUpdate, c)
+}
+
+func (c Catalog) AfterDelete(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeDelete, c)
+}
+
+func (b *Bundle) AfterCreate(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeCreate, b)
+}
+
+func (b *Bundle) AfterUpdate(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeUpdate, b)
+}
+
+func (b *Bundle) AfterDelete(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeDelete, b)
+}
+
+func (w Webhook) AfterCreate(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeCreate, w)
+}
+
+func (w Webhook) AfterDelete(trx *gorm.DB) error {
+ return emit(trx, common.EventTypeDelete, w)
+}
+
// pendingEventsKey addresses the collector Transaction leaves in the
// statement context for the hooks running under it.
type pendingEventsKey struct{}
diff --git a/internal/models/models.go b/internal/models/models.go
index 5d51294..271774f 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -216,15 +216,23 @@ type Webhook struct {
EntityType string `json:"-" gorm:"index:idx_webhook_url,unique"`
}
+func (Webhook) TableName() string {
+ return "webhooks"
+}
+
+func (w Webhook) UUID() string {
+ return w.ID
+}
+
// Event is one entry of the audit trail. Actor is the subject of the
// token that authenticated the write, empty when the token carries none.
+// An event is never soft deleted: the retention purge removes the row.
type Event struct {
- ID string `json:"id" gorm:"primaryKey"`
- Type string `json:"type"`
- EntityType string `json:"entityType"`
- EntityID string `json:"entityId"`
- Actor string `json:"actor,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+ ID string `json:"id" gorm:"primaryKey"`
+ Type string `json:"type"`
+ EntityType string `json:"entityType"`
+ EntityID string `json:"entityId"`
+ Actor string `json:"actor,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
}
diff --git a/internal/webhooks/webhooks_test.go b/internal/webhooks/webhooks_test.go
index 4f7bd3b..bf43296 100644
--- a/internal/webhooks/webhooks_test.go
+++ b/internal/webhooks/webhooks_test.go
@@ -41,7 +41,7 @@ func setupDB(t *testing.T, webhooks []models.Webhook) *gorm.DB {
})
require.NoError(t, err)
- require.NoError(t, db.AutoMigrate(&models.Webhook{}))
+ require.NoError(t, db.AutoMigrate(&models.Webhook{}, &models.Event{}))
for i := range webhooks {
require.NoError(t, db.Create(&webhooks[i]).Error)
diff --git a/open-catalog-api.oas.yaml b/open-catalog-api.oas.yaml
index a1e57ee..e16c424 100644
--- a/open-catalog-api.oas.yaml
+++ b/open-catalog-api.oas.yaml
@@ -542,6 +542,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
+ '409':
+ $ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
@@ -664,6 +666,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
+ '409':
+ $ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
@@ -936,6 +940,14 @@ paths:
in: query
name: search
description: Only logs matching the search string in their message
+ - schema:
+ type: string
+ maxLength: 255
+ pattern: '.*'
+ example: 'BAD publiccode'
+ in: query
+ name: filter
+ description: Only logs whose message is exactly this string
post:
summary: Create Logs
description: Create Logs
@@ -1120,6 +1132,40 @@ paths:
in: query
name: to
description: Only events before this time (RFC 3339 datetime)
+ - schema:
+ type: string
+ maxLength: 255
+ pattern: '.*'
+ example: 'software'
+ in: query
+ name: entityType
+ description: Only events on this resource collection
+ - schema:
+ type: string
+ maxLength: 36
+ pattern: '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}'
+ example: '12f30d9e-042e-41ed-8ddc-d8bbc146d165'
+ in: query
+ name: entityId
+ description: Only events on the entity with this id
+ - schema:
+ type: string
+ enum:
+ - create
+ - update
+ - delete
+ example: 'update'
+ in: query
+ name: type
+ description: Only events of this kind
+ - schema:
+ type: string
+ maxLength: 255
+ pattern: '.*'
+ example: 'crawler'
+ in: query
+ name: actor
+ description: Only events made by this actor, the subject of the token used for the change
'/events/{eventId}':
parameters:
- schema:
@@ -2175,6 +2221,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
+ '409':
+ $ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
@@ -2297,6 +2345,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
+ '409':
+ $ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'429':
diff --git a/publishers_test.go b/publishers_test.go
index bc6e03e..7f0dbb2 100644
--- a/publishers_test.go
+++ b/publishers_test.go
@@ -1203,6 +1203,23 @@ func TestPublishersDeleteDBChecks(t *testing.T) {
assert.Equal(t, 0, dbCount(t, "publishers_code_hosting", "publisher_id", publisherID))
})
+
+ t.Run("DELETE publisher soft deletes its logs", func(t *testing.T) {
+ loadFixtures(t)
+
+ const logID = "53650508-042e-11ed-9b84-d8bbc146d165"
+
+ req, err := newTestRequest("DELETE", "/v1/publishers/"+italiaPublisherID, nil)
+ require.NoError(t, err)
+ req.Header = map[string][]string{"Authorization": {goodToken}}
+
+ res, err := app.Test(req, -1)
+ require.NoError(t, err)
+ assert.Equal(t, 204, res.StatusCode)
+
+ assert.False(t, dbNull(t, "logs", "deleted_at", "id", logID))
+ assert.Equal(t, 1, dbCount(t, "events", "entity_id", italiaPublisherID))
+ })
}
func addPublishersForPaginationCapTest(t *testing.T) {
diff --git a/software_test.go b/software_test.go
index 1cc3e0f..fa912e0 100644
--- a/software_test.go
+++ b/software_test.go
@@ -1851,6 +1851,69 @@ func TestSoftwareAnalysisDBChecks(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("concurrent PATCH analysis preserves every namespace", func(t *testing.T) {
+ loadFixtures(t)
+
+ const softwareID = "59803fb7-8eec-4fe5-a354-8926009c364a"
+
+ bodies := []string{
+ `{"scanner-one":{"v":1,"score":90}}`,
+ `{"scanner-two":{"v":2,"grade":"A"}}`,
+ }
+ type patchResult struct {
+ status int
+ err error
+ }
+
+ start := make(chan struct{})
+ results := make(chan patchResult, len(bodies))
+
+ for _, body := range bodies {
+ go func() {
+ req, err := newTestRequest(
+ "PATCH",
+ "/v1/software/"+softwareID+"/analysis",
+ strings.NewReader(body),
+ )
+ if err != nil {
+ results <- patchResult{err: err}
+
+ return
+ }
+
+ req.Header = map[string][]string{
+ "Authorization": {goodToken},
+ "Content-Type": {"application/merge-patch+json"},
+ }
+
+ <-start
+
+ res, err := app.Test(req, -1)
+ status := 0
+ if res != nil {
+ status = res.StatusCode
+ _ = res.Body.Close()
+ }
+
+ results <- patchResult{status: status, err: err}
+ }()
+ }
+
+ close(start)
+ for range bodies {
+ result := <-results
+ require.NoError(t, result.err)
+ assert.Equal(t, 200, result.status)
+ }
+
+ raw := dbValue(t, "software", "analysis", "id", softwareID)
+
+ var analysis map[string]any
+ require.NoError(t, json.NewDecoder(strings.NewReader(raw)).Decode(&analysis))
+ assert.Contains(t, analysis, "scanner-one")
+ assert.Contains(t, analysis, "scanner-two")
+ })
}
func TestSoftwareDeleteDBChecks(t *testing.T) {
@@ -1874,6 +1937,42 @@ func TestSoftwareDeleteDBChecks(t *testing.T) {
assert.Equal(t, 0, dbCount(t, "software_urls", "software_id", softwareID))
})
+
+ t.Run("DELETE software soft deletes its logs", func(t *testing.T) {
+ loadFixtures(t)
+
+ const logID = "2dfb2bc2-042d-11ed-9338-d8bbc146d165"
+
+ req, err := newTestRequest("DELETE", "/v1/software/"+italiaSoftwareID, nil)
+ require.NoError(t, err)
+ req.Header = map[string][]string{"Authorization": {goodToken}}
+
+ res, err := app.Test(req, -1)
+ require.NoError(t, err)
+ assert.Equal(t, 204, res.StatusCode)
+
+ assert.False(t, dbNull(t, "logs", "deleted_at", "id", logID))
+ // The fixtures carry three events for this software, the delete
+ // adds one, and the logs going with it add none.
+ assert.Equal(t, 4, dbCount(t, "events", "entity_id", italiaSoftwareID))
+ assert.Equal(t, 0, dbCount(t, "events", "entity_type", "logs"))
+ })
+
+ t.Run("DELETE of a missing software records no event", func(t *testing.T) {
+ loadFixtures(t)
+
+ const softwareID = "00000000-dead-beef-0000-0000000000aa"
+
+ req, err := newTestRequest("DELETE", "/v1/software/"+softwareID, nil)
+ require.NoError(t, err)
+ req.Header = map[string][]string{"Authorization": {goodToken}}
+
+ res, err := app.Test(req, -1)
+ require.NoError(t, err)
+ assert.Equal(t, 404, res.StatusCode)
+
+ assert.Equal(t, 0, dbCount(t, "events", "entity_id", softwareID))
+ })
}
func addSoftwareForPaginationCapTest(t *testing.T) {
diff --git a/webhooks_test.go b/webhooks_test.go
index 186746c..14eabe1 100644
--- a/webhooks_test.go
+++ b/webhooks_test.go
@@ -42,6 +42,30 @@ func TestWebhooksEndpoints(t *testing.T) {
assert.Equal(t, "https://example.org/receiver", response["url"])
},
},
+ {
+ description: "POST webhook with the url of an existing one returns 409",
+ query: "POST /v1/software/webhooks",
+ body: `{"url": "https://2-b.example.org/receiver"}`,
+ headers: map[string][]string{
+ "Authorization": {goodToken},
+ "Content-Type": {"application/json"},
+ },
+ expectedCode: 409,
+ expectedBody: `{"title":"can't create Webhook","detail":"url already exists","status":409}`,
+ expectedContentType: "application/problem+json",
+ },
+ {
+ description: "POST webhook for a software with the url of an existing one returns 409",
+ query: "POST /v1/software/c5dec6fa-8a01-4881-9e7d-132770d4214d/webhooks",
+ body: `{"url": "https://1-b.example.org/receiver"}`,
+ headers: map[string][]string{
+ "Authorization": {goodToken},
+ "Content-Type": {"application/json"},
+ },
+ expectedCode: 409,
+ expectedBody: `{"title":"can't create Webhook","detail":"url already exists","status":409}`,
+ expectedContentType: "application/problem+json",
+ },
// GET /webhooks/:id
{
query: "GET /v1/webhooks/007bc84a-7e2d-43a0-b7e1-a256d4114aa7",
@@ -229,4 +253,20 @@ func TestWebhooksDeleteDBChecks(t *testing.T) {
assert.Equal(t, 0, dbCount(t, "webhooks", "id", webhookID))
})
+
+ t.Run("DELETE of a missing webhook records no event", func(t *testing.T) {
+ loadFixtures(t)
+
+ const webhookID = "00000000-dead-beef-0000-0000000000bb"
+
+ req, err := newTestRequest("DELETE", "/v1/webhooks/"+webhookID, nil)
+ require.NoError(t, err)
+ req.Header = map[string][]string{"Authorization": {goodToken}}
+
+ res, err := app.Test(req, -1)
+ require.NoError(t, err)
+ assert.Equal(t, 404, res.StatusCode)
+
+ assert.Equal(t, 0, dbCount(t, "events", "entity_id", webhookID))
+ })
}