diff --git a/cmd/api/src/api/v2/saved_queries.go b/cmd/api/src/api/v2/saved_queries.go index 132adada675..7f0eacbc12b 100644 --- a/cmd/api/src/api/v2/saved_queries.go +++ b/cmd/api/src/api/v2/saved_queries.go @@ -477,7 +477,7 @@ func (s Resources) CreateSavedQuery(response http.ResponseWriter, request *http. api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) } else if createRequest.Name == "" || createRequest.Query == "" { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "the name and/or query field is empty", request), response) - } else if savedQuery, err := s.DB.CreateSavedQuery(request.Context(), user.ID, createRequest.Name, createRequest.Query, createRequest.Description); err != nil { + } else if savedQuery, err := s.DB.CreateSavedQuery(request.Context(), user.ID, createRequest.Name, createRequest.Query, createRequest.Description, nil, nil); err != nil { if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "duplicate name for saved query: please choose a different name", request), response) } else { diff --git a/cmd/api/src/api/v2/saved_queries_test.go b/cmd/api/src/api/v2/saved_queries_test.go index 58f2a20a826..2aeef237372 100644 --- a/cmd/api/src/api/v2/saved_queries_test.go +++ b/cmd/api/src/api/v2/saved_queries_test.go @@ -189,7 +189,7 @@ func TestResources_CreateSavedQuery_DuplicateName(t *testing.T) { req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) - mockDB.EXPECT().CreateSavedQuery(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(model.SavedQuery{}, fmt.Errorf("duplicate key value violates unique constraint \"idx_saved_queries_composite_index\"")) + mockDB.EXPECT().CreateSavedQuery(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(model.SavedQuery{}, fmt.Errorf("duplicate key value violates unique constraint \"idx_saved_queries_composite_index\"")) router := mux.NewRouter() router.HandleFunc(endpoint, resources.CreateSavedQuery).Methods("POST") @@ -232,7 +232,7 @@ func TestResources_CreateSavedQuery_CreateFailure(t *testing.T) { req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) - mockDB.EXPECT().CreateSavedQuery(gomock.Any(), userId, payload["name"], payload["query"], payload["description"]).Return(model.SavedQuery{}, fmt.Errorf("foo")) + mockDB.EXPECT().CreateSavedQuery(gomock.Any(), userId, payload["name"], payload["query"], payload["description"], gomock.Any(), gomock.Any()).Return(model.SavedQuery{}, fmt.Errorf("foo")) router := mux.NewRouter() router.HandleFunc(endpoint, resources.CreateSavedQuery).Methods("POST") @@ -275,7 +275,7 @@ func TestResources_CreateSavedQuery(t *testing.T) { req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) - mockDB.EXPECT().CreateSavedQuery(gomock.Any(), userId, payload["name"], payload["query"], payload["description"]).Return(model.SavedQuery{ + mockDB.EXPECT().CreateSavedQuery(gomock.Any(), userId, payload["name"], payload["query"], payload["description"], gomock.Any(), gomock.Any()).Return(model.SavedQuery{ UserID: userId.String(), Name: fmt.Sprintf("%v", payload["name"]), Query: fmt.Sprintf("%v", payload["query"]), diff --git a/cmd/api/src/database/migration/migrations/20260901110807_v9_add_schema_extension_id_to_saved_queries.sql b/cmd/api/src/database/migration/migrations/20260901110807_v9_add_schema_extension_id_to_saved_queries.sql new file mode 100644 index 00000000000..d3e686d8332 --- /dev/null +++ b/cmd/api/src/database/migration/migrations/20260901110807_v9_add_schema_extension_id_to_saved_queries.sql @@ -0,0 +1,57 @@ +-- Copyright 2026 Specter Ops, Inc. +-- +-- Licensed under the Apache License, Version 2.0 +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +-- SPDX-License-Identifier: Apache-2.0 +-- +goose Up +ALTER TABLE saved_queries + ADD COLUMN IF NOT EXISTS schema_extension_id INTEGER REFERENCES schema_extensions (id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS query_key TEXT + CONSTRAINT chk_saved_queries_extension_shape + CHECK ( + (schema_extension_id IS NULL AND query_key IS NULL AND user_id <> '00000000-0000-0000-0000-000000000000') + OR + (schema_extension_id IS NOT NULL AND query_key IS NOT NULL AND user_id = '00000000-0000-0000-0000-000000000000') + ); + +CREATE INDEX IF NOT EXISTS idx_saved_queries_schema_extension_id + ON saved_queries (schema_extension_id) WHERE schema_extension_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_saved_queries_composite_index; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_queries_user_id_name + ON saved_queries (user_id, name) WHERE schema_extension_id IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_queries_schema_extension_id_name + ON saved_queries (schema_extension_id, name) WHERE schema_extension_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_queries_extension_query_key + ON saved_queries (schema_extension_id, query_key) WHERE schema_extension_id IS NOT NULL; + + +-- +goose Down + +DELETE FROM saved_queries WHERE schema_extension_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_saved_queries_user_id_name; +DROP INDEX IF EXISTS idx_saved_queries_schema_extension_id_name; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_queries_composite_index + ON saved_queries USING btree (user_id, name); + +DROP INDEX IF EXISTS idx_saved_queries_schema_extension_id; + +DROP INDEX IF EXISTS idx_saved_queries_extension_query_key; + +ALTER TABLE saved_queries + DROP COLUMN IF EXISTS query_key, + DROP COLUMN IF EXISTS schema_extension_id; diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index 75420d8abe9..54ca363eae4 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -611,18 +611,18 @@ func (mr *MockDatabaseMockRecorder) CreateSavedQueries(ctx, savedQueries any) *g } // CreateSavedQuery mocks base method. -func (m *MockDatabase) CreateSavedQuery(ctx context.Context, userID uuid.UUID, name, query, description string) (model.SavedQuery, error) { +func (m *MockDatabase) CreateSavedQuery(ctx context.Context, userID uuid.UUID, name, query, description string, schemaExtensionID *int32, queryKey *string) (model.SavedQuery, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateSavedQuery", ctx, userID, name, query, description) + ret := m.ctrl.Call(m, "CreateSavedQuery", ctx, userID, name, query, description, schemaExtensionID, queryKey) ret0, _ := ret[0].(model.SavedQuery) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateSavedQuery indicates an expected call of CreateSavedQuery. -func (mr *MockDatabaseMockRecorder) CreateSavedQuery(ctx, userID, name, query, description any) *gomock.Call { +func (mr *MockDatabaseMockRecorder) CreateSavedQuery(ctx, userID, name, query, description, schemaExtensionID, queryKey any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSavedQuery", reflect.TypeOf((*MockDatabase)(nil).CreateSavedQuery), ctx, userID, name, query, description) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSavedQuery", reflect.TypeOf((*MockDatabase)(nil).CreateSavedQuery), ctx, userID, name, query, description, schemaExtensionID, queryKey) } // CreateSavedQueryPermissionToPublic mocks base method. diff --git a/cmd/api/src/database/saved_queries.go b/cmd/api/src/database/saved_queries.go index 3f123919a55..dcdc960de72 100644 --- a/cmd/api/src/database/saved_queries.go +++ b/cmd/api/src/database/saved_queries.go @@ -30,7 +30,7 @@ import ( type SavedQueriesData interface { GetSavedQuery(ctx context.Context, savedQueryID int64) (model.SavedQuery, error) ListSavedQueries(ctx context.Context, scope string, userID uuid.UUID, order string, filter model.SQLFilter, skip, limit int) ([]model.ScopedSavedQuery, int, error) - CreateSavedQuery(ctx context.Context, userID uuid.UUID, name string, query string, description string) (model.SavedQuery, error) + CreateSavedQuery(ctx context.Context, userID uuid.UUID, name string, query string, description string, schemaExtensionID *int32, queryKey *string) (model.SavedQuery, error) UpdateSavedQuery(ctx context.Context, savedQuery model.SavedQuery) (model.SavedQuery, error) DeleteSavedQuery(ctx context.Context, savedQueryID int64) error SavedQueryBelongsToUser(ctx context.Context, userID uuid.UUID, savedQueryID int64) (bool, error) @@ -97,19 +97,23 @@ func (s *BloodhoundDB) ListSavedQueries(ctx context.Context, scope string, userI return queries, int(count), CheckError(result) } -func (s *BloodhoundDB) CreateSavedQuery(ctx context.Context, userID uuid.UUID, name string, query string, description string) (model.SavedQuery, error) { +func (s *BloodhoundDB) CreateSavedQuery(ctx context.Context, userID uuid.UUID, name string, query string, description string, schemaExtensionID *int32, queryKey *string) (model.SavedQuery, error) { savedQuery := model.SavedQuery{ - UserID: userID.String(), - Name: name, - Query: query, - Description: description, + UserID: userID.String(), + Name: name, + Query: query, + Description: description, + SchemaExtensionID: schemaExtensionID, + QueryKey: queryKey, } - return savedQuery, CheckError(s.db.WithContext(ctx).Create(&savedQuery)) + result := s.db.WithContext(ctx).Create(&savedQuery) + return savedQuery, CheckError(result) } func (s *BloodhoundDB) UpdateSavedQuery(ctx context.Context, savedQuery model.SavedQuery) (model.SavedQuery, error) { - return savedQuery, CheckError(s.db.WithContext(ctx).Save(&savedQuery)) + result := s.db.WithContext(ctx).Save(&savedQuery) + return savedQuery, CheckError(result) } func (s *BloodhoundDB) DeleteSavedQuery(ctx context.Context, savedQueryID int64) error { @@ -127,18 +131,14 @@ func (s *BloodhoundDB) SavedQueryBelongsToUser(ctx context.Context, userID uuid. // GetSharedSavedQueries returns all the saved queries that the given userID has access to, including global queries func (s *BloodhoundDB) GetSharedSavedQueries(ctx context.Context, userID uuid.UUID) (model.SavedQueries, error) { savedQueries := model.SavedQueries{} - result := s.db.WithContext(ctx).Select("saved_queries.*").Joins("JOIN saved_queries_permissions sqp ON sqp.query_id = saved_queries.id").Where("sqp.shared_to_user_id = ? ", userID).Find(&savedQueries) - return savedQueries, CheckError(result) } // GetPublicSavedQueries returns all the queries that were shared publicly func (s *BloodhoundDB) GetPublicSavedQueries(ctx context.Context) (model.SavedQueries, error) { savedQueries := model.SavedQueries{} - result := s.db.WithContext(ctx).Select("saved_queries.*").Joins("JOIN saved_queries_permissions sqp ON sqp.query_id = saved_queries.id").Where("sqp.public = true").Find(&savedQueries) - return savedQueries, CheckError(result) } diff --git a/cmd/api/src/database/saved_queries_integration_test.go b/cmd/api/src/database/saved_queries_integration_test.go index 24589c207a4..fba6ac4431e 100644 --- a/cmd/api/src/database/saved_queries_integration_test.go +++ b/cmd/api/src/database/saved_queries_integration_test.go @@ -20,12 +20,16 @@ package database_test import ( "context" + "errors" "fmt" "testing" "github.com/gofrs/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/specterops/bloodhound/cmd/api/src/database" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/test/integration" ) @@ -48,7 +52,7 @@ func TestSavedQueries_ListSavedQueries(t *testing.T) { require.Nil(t, err) for i := 0; i < 7; i++ { - if _, err := dbInst.CreateSavedQuery(testCtx, userUUID, fmt.Sprintf("saved_query_%d", i), "", ""); err != nil { + if _, err := dbInst.CreateSavedQuery(testCtx, userUUID, fmt.Sprintf("saved_query_%d", i), "", "", nil, nil); err != nil { t.Fatalf("Error creating audit log: %v", err) } } @@ -67,3 +71,322 @@ func TestSavedQueries_ListSavedQueries(t *testing.T) { t.Fatalf("Expected 3 saved queries to be returned, received %d", count) } } + +func assertSavedQueryConstraintError(t *testing.T, err error, expectedConstraint string) { + t.Helper() + + var pgErr *pgconn.PgError + require.Error(t, err) + require.True(t, errors.As(err, &pgErr), "expected wrapped *pgconn.PgError, got %T: %v", err, err) + assert.Equal(t, expectedConstraint, pgErr.ConstraintName) +} + +func TestSavedQueries_CreateSavedQuery(t *testing.T) { + t.Parallel() + + var ( + suite = setupIntegrationTestSuite(t) + userUUID, uErr = uuid.NewV4() + ) + t.Cleanup(func() { + teardownIntegrationTestSuite(t, &suite) + }) + require.NoError(t, uErr) + + type testSetupData struct { + name string + savedQueryID int64 + query string + description string + schemaExtensionID *int32 + queryKey *string + } + type testCase struct { + name string + setup func(t *testing.T, suite IntegrationTestSuite) testSetupData + assert func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery + teardown func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) + } + + stringPtr := func(value string) *string { + return &value + } + + tests := []testCase{ + { + name: "success_-_extension_and_query_key_persist", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + ext, err := suite.BHDatabase.CreateGraphSchemaExtension(suite.Context, "CreateSQPersistExt", "Create SQ Persist Ext", "v1.0.0", "create_sq_persist_ns") + require.NoError(t, err) + return testSetupData{ + name: "ext_query", + query: "MATCH (n) RETURN n", + description: "desc", + schemaExtensionID: &ext.ID, + queryKey: stringPtr("ext_query_key"), + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + require.NoError(t, err) + assert.Equal(t, setupData.schemaExtensionID, created.SchemaExtensionID) + assert.Equal(t, setupData.queryKey, created.QueryKey) + + fetched, err := suite.BHDatabase.GetSavedQuery(suite.Context, created.ID) + require.NoError(t, err) + assert.Equal(t, setupData.schemaExtensionID, fetched.SchemaExtensionID) + assert.Equal(t, setupData.queryKey, fetched.QueryKey) + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteGraphSchemaExtension(suite.Context, *setupData.schemaExtensionID)) + }, + }, + { + name: "success_-_no_extension_or_query_key_persists_as_null", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + return testSetupData{ + name: "user_query", + query: "MATCH (n) RETURN n", + description: "desc", + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, userUUID, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + require.NoError(t, err) + assert.Nil(t, created.SchemaExtensionID) + assert.Nil(t, created.QueryKey) + + fetched, err := suite.BHDatabase.GetSavedQuery(suite.Context, created.ID) + require.NoError(t, err) + assert.Nil(t, fetched.SchemaExtensionID) + assert.Nil(t, fetched.QueryKey) + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteSavedQuery(suite.Context, created.ID)) + }, + }, + { + name: "success_-_deleting_extension_cascades_to_saved_query", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + ext, err := suite.BHDatabase.CreateGraphSchemaExtension(suite.Context, "CascadeExt", "Cascade Ext", "v1.0.0", "cascade_ns") + require.NoError(t, err) + return testSetupData{ + name: "cascade_query", + query: "MATCH (n) RETURN n", + description: "desc", + schemaExtensionID: &ext.ID, + queryKey: stringPtr("cascade_query_key"), + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + require.NoError(t, err) + require.NoError(t, suite.BHDatabase.DeleteGraphSchemaExtension(suite.Context, *setupData.schemaExtensionID)) + + _, err = suite.BHDatabase.GetSavedQuery(suite.Context, created.ID) + assert.ErrorIs(t, err, database.ErrNotFound) + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + // The extension (and its cascaded saved query) is already removed by the assert step. + }, + }, + { + name: "error_-_extension_set_but_query_key_nil", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + ext, err := suite.BHDatabase.CreateGraphSchemaExtension(suite.Context, "ExtNoKeyExt", "Ext No Key Ext", "v1.0.0", "ext_no_key_ns") + require.NoError(t, err) + return testSetupData{ + name: "ext_no_key", + query: "MATCH (n) RETURN n", + description: "desc", + schemaExtensionID: &ext.ID, + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + assertSavedQueryConstraintError(t, err, "chk_saved_queries_extension_shape") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteGraphSchemaExtension(suite.Context, *setupData.schemaExtensionID)) + }, + }, + { + name: "error_-_query_key_set_but_extension_nil", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + return testSetupData{ + name: "key_no_ext", + query: "MATCH (n) RETURN n", + description: "desc", + queryKey: stringPtr("k"), + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, userUUID, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + assertSavedQueryConstraintError(t, err, "chk_saved_queries_extension_shape") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + // Nothing was persisted: the insert is rejected and no extension is created. + }, + }, + { + name: "error_-_duplicate_user_saved_query_name", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + name := "duplicate_user_name" + savedQuery, err := suite.BHDatabase.CreateSavedQuery(suite.Context, userUUID, name, "MATCH (n) RETURN n", "desc", nil, nil) + require.NoError(t, err) + return testSetupData{ + name: name, + savedQueryID: savedQuery.ID, + query: "MATCH (n) RETURN n", + description: "desc", + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, userUUID, setupData.name, setupData.query, setupData.description, nil, nil) + assertSavedQueryConstraintError(t, err, "idx_saved_queries_user_id_name") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteSavedQuery(suite.Context, setupData.savedQueryID)) + }, + }, + { + name: "error_-_duplicate_extension_saved_query_name", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + ext, err := suite.BHDatabase.CreateGraphSchemaExtension(suite.Context, "DupNameExt", "Dup Name Ext", "v1.0.0", "dup_name_ns") + require.NoError(t, err) + + name := "duplicate_extension_name" + _, err = suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, name, "MATCH (n) RETURN n", "desc", &ext.ID, stringPtr("first")) + require.NoError(t, err) + + return testSetupData{ + name: name, + query: "MATCH (n) RETURN n", + description: "desc", + schemaExtensionID: &ext.ID, + queryKey: stringPtr("second"), + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + assertSavedQueryConstraintError(t, err, "idx_saved_queries_schema_extension_id_name") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteGraphSchemaExtension(suite.Context, *setupData.schemaExtensionID)) + }, + }, + { + name: "error_-_duplicate_extension_query_key", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + ext, err := suite.BHDatabase.CreateGraphSchemaExtension(suite.Context, "DupKeyExt", "Dup Key Ext", "v1.0.0", "dup_key_ns") + require.NoError(t, err) + + _, err = suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, "dup_first", "MATCH (n) RETURN n", "desc", &ext.ID, stringPtr("dup")) + require.NoError(t, err) + + return testSetupData{ + name: "dup_second", + query: "MATCH (n) RETURN n", + description: "desc", + schemaExtensionID: &ext.ID, + queryKey: stringPtr("dup"), + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + assertSavedQueryConstraintError(t, err, "idx_saved_queries_extension_query_key") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteGraphSchemaExtension(suite.Context, *setupData.schemaExtensionID)) + }, + }, + { + name: "error_-_extension_with_real_user_id_rejected", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + ext, err := suite.BHDatabase.CreateGraphSchemaExtension(suite.Context, "ExtRealUserExt", "Ext Real User Ext", "v1.0.0", "ext_real_user_ns") + require.NoError(t, err) + return testSetupData{ + name: "ext_real_user", + query: "MATCH (n) RETURN n", + description: "desc", + schemaExtensionID: &ext.ID, + queryKey: stringPtr("ext_real_user_key"), + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, userUUID, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + assertSavedQueryConstraintError(t, err, "chk_saved_queries_extension_shape") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + require.NoError(t, suite.BHDatabase.DeleteGraphSchemaExtension(suite.Context, *setupData.schemaExtensionID)) + }, + }, + { + name: "error_-_system_owner_without_extension_rejected", + setup: func(t *testing.T, suite IntegrationTestSuite) testSetupData { + t.Helper() + return testSetupData{ + name: "system_owner_no_ext", + query: "MATCH (n) RETURN n", + description: "desc", + } + }, + assert: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData) model.SavedQuery { + t.Helper() + created, err := suite.BHDatabase.CreateSavedQuery(suite.Context, uuid.Nil, setupData.name, setupData.query, setupData.description, setupData.schemaExtensionID, setupData.queryKey) + assertSavedQueryConstraintError(t, err, "chk_saved_queries_extension_shape") + return created + }, + teardown: func(t *testing.T, suite IntegrationTestSuite, setupData testSetupData, created model.SavedQuery) { + t.Helper() + // Nothing was persisted: the insert is rejected by chk_saved_queries_extension_shape. + }, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + setupData := testCase.setup(t, suite) + created := testCase.assert(t, suite, setupData) + testCase.teardown(t, suite, setupData, created) + }) + } +} diff --git a/cmd/api/src/database/saved_queries_permissions_integration_test.go b/cmd/api/src/database/saved_queries_permissions_integration_test.go index d39cfd42fd8..ad4e75cb817 100644 --- a/cmd/api/src/database/saved_queries_permissions_integration_test.go +++ b/cmd/api/src/database/saved_queries_permissions_integration_test.go @@ -40,7 +40,7 @@ func TestSavedQueriesPermissions_CreateSavedQueryPermissionToPublic(t *testing.T ) t.Run("Creates saved query permission to public", func(t *testing.T) { - query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionToPublic(testCtx, query.ID) @@ -56,7 +56,7 @@ func TestSavedQueriesPermissions_CreateSavedQueryPermissionToPublic(t *testing.T }) t.Run("Creates saved query permission to public while deleting previous user's shared query permission", func(t *testing.T) { - query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query2", "TESTING2", "Example2") + query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query2", "TESTING2", "Example2", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user2.ID) @@ -93,7 +93,7 @@ func TestSavedQueriesPermissions_CreateSavedQueryPermissionsToUsers(t *testing.T user4 = createUser(t, dbInst, user4Principal) ) - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) // Share with Users 2 and 3 and ensure its not shared with user 4 @@ -163,7 +163,7 @@ func TestSavedQueriesPermissions_CreateSavedQueryPermissionsBatchBadDataError(t unknownUUID, _ := uuid.NewV4() - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user2.ID, unknownUUID) @@ -195,7 +195,7 @@ func TestSavedQueriesPermissions_GetScopeForSavedQueryPublic(t *testing.T) { user2 = createUser(t, dbInst, user2Principal) ) - query, err := dbInst.CreateSavedQuery(testCtx, user2.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user2.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionToPublic(testCtx, query.ID) @@ -219,7 +219,7 @@ func TestSavedQueriesPermissions_GetScopeForSavedQueryShared(t *testing.T) { user2 = createUser(t, dbInst, user2Principal) ) - query, err := dbInst.CreateSavedQuery(testCtx, user2.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user2.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user1.ID) @@ -243,7 +243,7 @@ func TestSavedQueriesPermissions_GetScopeForSavedQueryOwned(t *testing.T) { user2 = createUser(t, dbInst, user2Principal) ) - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user2.ID) @@ -269,7 +269,7 @@ func TestSavedQueriesPermissions_DeleteSavedQueryPermissionsForUsers(t *testing. ) t.Run("Deletes saved query permissions for user(s)", func(t *testing.T) { - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user2.ID, user3.ID) @@ -312,7 +312,7 @@ func TestSavedQueriesPermissions_DeleteSavedQueryPermissionsForUsers(t *testing. }) t.Run("Deletes saved query permissions given no provided users", func(t *testing.T) { - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query2", "TESTING2", "Example2") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query2", "TESTING2", "Example2", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user2.ID) @@ -345,7 +345,7 @@ func TestSavedQueriesPermissions_IsSavedQueryPublic(t *testing.T) { dbInst, user1 = initAndCreateUser(t) ) - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionToPublic(testCtx, query.ID) @@ -362,7 +362,7 @@ func TestSavedQueriesPermissions_IsSavedQuerySharedToUser(t *testing.T) { dbInst, user1 = initAndCreateUser(t) ) - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user1.ID) @@ -391,7 +391,7 @@ func TestSavedQueriesPermissions_GetSavedQueryPermissions(t *testing.T) { }} ) - query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Test Description") + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Test Description", nil, nil) require.NoError(t, err) _, err = dbInst.CreateSavedQueryPermissionsToUsers(testCtx, query.ID, user2.ID) require.NoError(t, err) diff --git a/cmd/api/src/model/saved_queries.go b/cmd/api/src/model/saved_queries.go index b91b0f8379c..f29b8802c1e 100644 --- a/cmd/api/src/model/saved_queries.go +++ b/cmd/api/src/model/saved_queries.go @@ -21,11 +21,12 @@ import ( ) type SavedQuery struct { - UserID string `json:"user_id" gorm:"index:,unique,composite:compositeIndex"` - Name string `json:"name" gorm:"index:,unique,composite:compositeIndex"` - Query string `json:"query"` - Description string `json:"description"` - + UserID string `json:"user_id" gorm:"index:,unique,composite:compositeIndex"` + Name string `json:"name" gorm:"index:,unique,composite:compositeIndex"` + Query string `json:"query"` + Description string `json:"description"` + SchemaExtensionID *int32 `json:"-" gorm:"column:schema_extension_id;index:,unique,composite:compositeIndex;index:,unique,composite:extensionQueryKey"` + QueryKey *string `json:"-" gorm:"column:query_key;index:,unique,composite:extensionQueryKey"` BigSerial }