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 @@

- - + + License

@@ -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 -![Version: 0.7.0](https://img.shields.io/badge/Version-0.7.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.4.0](https://img.shields.io/badge/AppVersion-v1.4.0-informational?style=flat-square) +![Version: 0.8.0](https://img.shields.io/badge/Version-0.8.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.4.0](https://img.shields.io/badge/AppVersion-v1.4.0-informational?style=flat-square) 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)) + }) }