diff --git a/internal/entities/properties/service/helpers.go b/internal/entities/properties/service/helpers.go index 71201ab771..9fbf89faed 100644 --- a/internal/entities/properties/service/helpers.go +++ b/internal/entities/properties/service/helpers.go @@ -20,6 +20,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "time" "github.com/google/uuid" @@ -143,35 +144,191 @@ func getEntityIdByName( return ent.ID, nil } -func getEntityIdByUpstreamID( - ctx context.Context, projectId uuid.UUID, +func getAllByProperty( + ctx context.Context, + propName string, + propVal any, + entType minderv1.Entity, + projectID uuid.UUID, providerID uuid.UUID, - upstreamID string, entType minderv1.Entity, qtx db.ExtendQuerier, -) (uuid.UUID, error) { +) ([]db.EntityInstance, error) { + l := zerolog.Ctx(ctx).With().Any(propName, propVal).Logger() + ents, err := qtx.GetTypedEntitiesByPropertyV1( ctx, entities.EntityTypeToDB(entType), - properties.PropertyUpstreamID, - upstreamID, + propName, + propVal, db.GetTypedEntitiesOptions{ - ProjectID: projectId, + ProjectID: projectID, ProviderID: providerID, }) if errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, ErrEntityNotFound + return nil, ErrEntityNotFound } else if err != nil { - return uuid.Nil, fmt.Errorf("error fetching entities by property: %w", err) + return nil, fmt.Errorf("error fetching entities by property: %w", err) + } + + if len(ents) == 0 { + l.Debug().Msg("no entity found") + return nil, ErrEntityNotFound + } + + return ents, nil +} + +func getEntityIdByUpstreamID( + ctx context.Context, + projectID uuid.UUID, providerID uuid.UUID, + upstreamID string, entType minderv1.Entity, + qtx db.ExtendQuerier, +) (uuid.UUID, error) { + ents, err := getAllByProperty(ctx, properties.PropertyUpstreamID, upstreamID, entType, projectID, providerID, qtx) + if err != nil { + return uuid.Nil, err } if len(ents) > 1 { return uuid.Nil, ErrMultipleEntities - } else if len(ents) == 1 { - return ents[0].ID, nil } - // no entity found - return uuid.Nil, ErrEntityNotFound + return ents[0].ID, nil +} + +func matchEntityWithHint( + ctx context.Context, + props *properties.Properties, + entType minderv1.Entity, + hint *ByUpstreamHint, + l zerolog.Logger, + qtx db.ExtendQuerier, +) (*db.EntityInstance, error) { + if !hint.isSet() { + return nil, fmt.Errorf("at least one of projectID, providerID or providerImplements must be set in hint") + } + + var ents []db.EntityInstance + var err error + + lookupOrder := []string{properties.PropertyUpstreamID, properties.PropertyName} + for _, loopupProp := range lookupOrder { + prop := props.GetProperty(loopupProp) + if prop == nil { + continue + } + + l.Debug().Str("lookupProp", loopupProp).Msg("fetching by property") + ents, err = getAllByProperty(ctx, loopupProp, prop.RawValue(), entType, hint.projectID, hint.providerID, qtx) + if errors.Is(err, ErrEntityNotFound) { + l.Debug().Str("lookupProp", loopupProp).Msg("no entity found") + continue + } else if err != nil { + return nil, fmt.Errorf("failed to get entities by upstream ID: %w", err) + } + + match, err := findMatchByUpstreamHint(ctx, ents, hint, qtx) + if err != nil { + if errors.Is(err, ErrEntityNotFound) { + l.Error().Msg("no entity matched") + continue + } else if errors.Is(err, ErrMultipleEntities) { + l.Error().Msg("multiple entities matched") + return nil, ErrMultipleEntities + } + return nil, fmt.Errorf("failed to match entity by hint: %w", err) + } + return match, nil + } + + return nil, ErrEntityNotFound +} + +func findMatchByUpstreamHint( + ctx context.Context, ents []db.EntityInstance, hint *ByUpstreamHint, qtx db.ExtendQuerier, +) (*db.EntityInstance, error) { + var match *db.EntityInstance + for _, ent := range ents { + var thisMatch *db.EntityInstance + zerolog.Ctx(ctx).Debug().Msgf("matching entity %s", ent.ID.String()) + if dbEntMatchesUpstreamHint(ctx, ent, hint, qtx) { + zerolog.Ctx(ctx).Debug().Msgf("entity %s matched by hint", ent.ID.String()) + thisMatch = &ent + } + + if thisMatch != nil { + if match != nil { + zerolog.Ctx(ctx).Error().Msg("multiple entities matched") + return nil, ErrMultipleEntities + } + match = thisMatch + } + } + + if match == nil { + zerolog.Ctx(ctx).Debug().Msg("no entity matched") + return nil, ErrEntityNotFound + } + + return match, nil +} + +func dbEntMatchesUpstreamHint(ctx context.Context, ent db.EntityInstance, hint *ByUpstreamHint, qtx db.ExtendQuerier) bool { + logger := zerolog.Ctx(ctx) + + if hint.projectID != uuid.Nil { + if ent.ProjectID != hint.projectID { + logger.Debug(). + Str("projectID", ent.ProjectID.String()). + Str("hintProjectID", hint.projectID.String()). + Msg("project mismatch") + return false + } + } + + if hint.providerID != uuid.Nil { + if ent.ProviderID != hint.providerID { + logger.Debug(). + Str("providerID", ent.ProviderID.String()). + Str("hintProviderID", hint.providerID.String()). + Msg("provider mismatch") + return false + } + } + + if hint.ProviderImplements.Valid { + dbProv, err := qtx.GetProviderByID(ctx, ent.ProviderID) + if err != nil { + logger.Error(). + Str("providerID", ent.ProviderID.String()). + Err(err). + Msg("error getting provider by ID") + return false + } + + if !slices.Contains(dbProv.Implements, hint.ProviderImplements.ProviderType) { + logger.Debug(). + Str("ProviderID", ent.ProviderID.String()). + Str("providerType", string(hint.ProviderImplements.ProviderType)). + Msg("provider does not implement hint") + return false + } + } + + return true +} + +func propsMatchUpstreamHint(props *properties.Properties, hint ByUpstreamHint) bool { + if hint.PropName == "" { + return true + } + + prop := props.GetProperty(hint.PropName) + if prop == nil { + return false + } + + return prop.RawValue() == hint.PropValue } func (ps *propertiesService) areDatabasePropertiesValid( diff --git a/internal/entities/properties/service/mock/service.go b/internal/entities/properties/service/mock/service.go index e9e52f8807..2b4caa4981 100644 --- a/internal/entities/properties/service/mock/service.go +++ b/internal/entities/properties/service/mock/service.go @@ -47,6 +47,21 @@ func (m *MockPropertiesService) EXPECT() *MockPropertiesServiceMockRecorder { return m.recorder } +// EntityWithPropertiesAsProto mocks base method. +func (m *MockPropertiesService) EntityWithPropertiesAsProto(ctx context.Context, ewp *models.EntityWithProperties, provMgr manager.ProviderManager) (protoreflect.ProtoMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EntityWithPropertiesAsProto", ctx, ewp, provMgr) + ret0, _ := ret[0].(protoreflect.ProtoMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EntityWithPropertiesAsProto indicates an expected call of EntityWithPropertiesAsProto. +func (mr *MockPropertiesServiceMockRecorder) EntityWithPropertiesAsProto(ctx, ewp, provMgr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EntityWithPropertiesAsProto", reflect.TypeOf((*MockPropertiesService)(nil).EntityWithPropertiesAsProto), ctx, ewp, provMgr) +} + // EntityWithPropertiesByID mocks base method. func (m *MockPropertiesService) EntityWithPropertiesByID(ctx context.Context, entityID uuid.UUID, opts *service.CallOptions) (*models.EntityWithProperties, error) { m.ctrl.T.Helper() @@ -62,19 +77,19 @@ func (mr *MockPropertiesServiceMockRecorder) EntityWithPropertiesByID(ctx, entit return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EntityWithPropertiesByID", reflect.TypeOf((*MockPropertiesService)(nil).EntityWithPropertiesByID), ctx, entityID, opts) } -// EntityWithPropertiesAsProto mocks base method. -func (m *MockPropertiesService) EntityWithPropertiesAsProto(ctx context.Context, ewp *models.EntityWithProperties, provMgr manager.ProviderManager) (protoreflect.ProtoMessage, error) { +// EntityWithPropertiesByUpstreamHint mocks base method. +func (m *MockPropertiesService) EntityWithPropertiesByUpstreamHint(ctx context.Context, entType v1.Entity, getByProps *properties.Properties, hint service.ByUpstreamHint, opts *service.CallOptions) (*models.EntityWithProperties, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EntityWithPropertiesAsProto", ctx, ewp, provMgr) - ret0, _ := ret[0].(protoreflect.ProtoMessage) + ret := m.ctrl.Call(m, "EntityWithPropertiesByUpstreamHint", ctx, entType, getByProps, hint, opts) + ret0, _ := ret[0].(*models.EntityWithProperties) ret1, _ := ret[1].(error) return ret0, ret1 } -// EntityWithPropertiesAsProto indicates an expected call of EntityWithPropertiesAsProto. -func (mr *MockPropertiesServiceMockRecorder) EntityWithPropertiesAsProto(ctx, ewp, provMgr any) *gomock.Call { +// EntityWithPropertiesByUpstreamHint indicates an expected call of EntityWithPropertiesByUpstreamHint. +func (mr *MockPropertiesServiceMockRecorder) EntityWithPropertiesByUpstreamHint(ctx, entType, getByProps, hint, opts any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EntityWithPropertiesAsProto", reflect.TypeOf((*MockPropertiesService)(nil).EntityWithPropertiesAsProto), ctx, ewp, provMgr) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EntityWithPropertiesByUpstreamHint", reflect.TypeOf((*MockPropertiesService)(nil).EntityWithPropertiesByUpstreamHint), ctx, entType, getByProps, hint, opts) } // ReplaceAllProperties mocks base method. diff --git a/internal/entities/properties/service/service.go b/internal/entities/properties/service/service.go index bab4e6e88a..21a10cbb4b 100644 --- a/internal/entities/properties/service/service.go +++ b/internal/entities/properties/service/service.go @@ -64,6 +64,16 @@ type PropertiesService interface { EntityWithPropertiesByID( ctx context.Context, entityID uuid.UUID, opts *CallOptions, ) (*models.EntityWithProperties, error) + // EntityWithPropertiesByUpstreamHint fetches an entity by upstream properties + // and returns the entity with its properties. It is expected that the caller + // does NOT know the project or provider ID. Whatever hints it may have + // are to be passed to the hint parameter which will be used in case multiple + // entries with the same ID are found. + EntityWithPropertiesByUpstreamHint( + ctx context.Context, + entType minderv1.Entity, getByProps *properties.Properties, + hint ByUpstreamHint, opts *CallOptions, + ) (*models.EntityWithProperties, error) // RetrieveAllProperties fetches all properties for an entity // given a project, provider, and identifying properties. // If the entity has properties in the database, it will return those @@ -290,28 +300,14 @@ func (ps *propertiesService) ReplaceProperty( return err } -func (ps *propertiesService) EntityWithPropertiesByID( - ctx context.Context, entityID uuid.UUID, +func (ps *propertiesService) getEntityWithProperties( + ctx context.Context, + ent db.EntityInstance, opts *CallOptions, ) (*models.EntityWithProperties, error) { q := ps.getStoreOrTransaction(opts) - zerolog.Ctx(ctx).Debug().Str("entityID", entityID.String()).Msg("fetching entity with properties") - ent, err := q.GetEntityByID(ctx, entityID) - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrEntityNotFound - } else if err != nil { - return nil, fmt.Errorf("error getting entity: %w", err) - } - zerolog.Ctx(ctx).Debug(). - Str("projectID", ent.ProjectID.String()). - Str("providerID", ent.ProviderID.String()). - Str("entityType", string(ent.EntityType)). - Str("entityName", ent.Name). - Str("entityID", ent.ID.String()). - Msg("entity found") - - dbProps, err := q.GetAllPropertiesForEntity(ctx, entityID) + dbProps, err := q.GetAllPropertiesForEntity(ctx, ent.ID) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("failed to get properties for entity: %w", ErrEntityNotFound) } else if err != nil { @@ -337,6 +333,95 @@ func (ps *propertiesService) EntityWithPropertiesByID( return models.NewEntityWithProperties(ent, props), nil } +func (ps *propertiesService) EntityWithPropertiesByID( + ctx context.Context, entityID uuid.UUID, + opts *CallOptions, +) (*models.EntityWithProperties, error) { + q := ps.getStoreOrTransaction(opts) + + zerolog.Ctx(ctx).Debug().Str("entityID", entityID.String()).Msg("fetching entity with properties") + ent, err := q.GetEntityByID(ctx, entityID) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrEntityNotFound + } else if err != nil { + return nil, fmt.Errorf("error getting entity: %w", err) + } + + return ps.getEntityWithProperties(ctx, ent, opts) +} + +// ByUpstreamHint is a hint to help find an entity by upstream ID +// API-wise it should only be exposed in those methods of the service layer +// that do not know the project or provider ID and are searching for an entity +// based on upstream properties only. +// The hint +type ByUpstreamHint struct { + // PropName is the name of the property to search by + // Note that this is an additional filtering criteria, not the only one. + // if neither of projectID, providerID or ProviderImplements is set and the entity + // has multiple entries, the search will fail. + PropName string + PropValue any + + // ProviderImplements is the provider type to search by + ProviderImplements db.NullProviderType + + // providerID and projectID are the IDs of the provider and project to search by. These are used + // internally by the module to be able to pass the hint to the database layer and are not exposed + // to the API. + providerID uuid.UUID + projectID uuid.UUID +} + +// ToLogDict converts the hint to a log dictionary for use by zerolog +func (hint *ByUpstreamHint) ToLogDict() *zerolog.Event { + dict := zerolog.Dict(). + Str("PropName", hint.PropName). + Interface("PropValue", hint.PropValue). + Interface("ProviderImplements", hint.ProviderImplements). + Str("providerID", hint.providerID.String()). + Str("projectID", hint.projectID.String()) + return dict +} + +func (hint *ByUpstreamHint) isSet() bool { + return hint.projectID != uuid.Nil || hint.providerID != uuid.Nil || hint.ProviderImplements.Valid +} + +func (ps *propertiesService) EntityWithPropertiesByUpstreamHint( + ctx context.Context, + entType minderv1.Entity, + getByProps *properties.Properties, + hint ByUpstreamHint, + opts *CallOptions, +) (*models.EntityWithProperties, error) { + q := ps.getStoreOrTransaction(opts) + + l := zerolog.Ctx(ctx).With(). + Dict("getByProps", getByProps.ToLogDict()). + Dict("hint", hint.ToLogDict()). + Logger() + + l.Debug().Msg("fetching entity with properties by upstream hint") + + ent, err := matchEntityWithHint(ctx, getByProps, entType, &hint, l, q) + if err != nil { + return nil, fmt.Errorf("failed to get entity ID: %w", err) + } + + ewp, err := ps.getEntityWithProperties(ctx, *ent, opts) + if err != nil { + return nil, err + } + + if !propsMatchUpstreamHint(ewp.Properties, hint) { + zerolog.Ctx(ctx).Debug().Msg("properties do not match hint") + return nil, ErrEntityNotFound + } + + return ewp, nil +} + // EntityWithPropertiesAsProto converts the entity with properties to a protobuf message func (_ *propertiesService) EntityWithPropertiesAsProto( ctx context.Context, ewp *models.EntityWithProperties, provMgr manager.ProviderManager, diff --git a/internal/entities/properties/service/service_test.go b/internal/entities/properties/service/service_test.go index 691b1bef9b..fbd2849a3a 100644 --- a/internal/entities/properties/service/service_test.go +++ b/internal/entities/properties/service/service_test.go @@ -19,6 +19,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "testing" "time" @@ -111,9 +112,10 @@ type fetchParams struct { } type testCtx struct { - testQueries db.Store - dbProj db.Project - ghAppProvider db.Provider + testQueries db.Store + dbProj db.Project + ghAppProvider db.Provider + dockerHubProvider db.Provider } func createTestCtx(ctx context.Context, t *testing.T) testCtx { @@ -141,10 +143,22 @@ func createTestCtx(ctx context.Context, t *testing.T) testCtx { }) require.NoError(t, err) + dockerHubProvider, err := testQueries.CreateProvider(context.Background(), + db.CreateProviderParams{ + Name: rand.RandomName(seed), + ProjectID: dbProj.ID, + Class: db.ProviderClassDockerhub, + Implements: []db.ProviderType{db.ProviderTypeOci}, + AuthFlows: []db.AuthorizationFlow{db.AuthorizationFlowOauth2AuthorizationCodeFlow}, + Definition: json.RawMessage("{}"), + }) + require.NoError(t, err) + return testCtx{ - testQueries: testQueries, - dbProj: dbProj, - ghAppProvider: ghAppProvider, + testQueries: testQueries, + dbProj: dbProj, + ghAppProvider: ghAppProvider, + dockerHubProvider: dockerHubProvider, } } @@ -603,6 +617,282 @@ func TestPropertiesService_RetrieveProperty(t *testing.T) { } } +func TestPropertiesService_EntityWithPropertiesByUpstreamHint(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + tctx := createTestCtx(ctx, t) + + scenarios := []struct { + name string + entType minderv1.Entity + byPropMap map[string]any + upstreamID string + hint ByUpstreamHint + dbSetup func(t *testing.T, store db.Store) + expectedError error + checkResult func(t *testing.T, result *models.EntityWithProperties) + }{ + { + name: "Successful retrieval by ID with project and provider ID hints", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "123"}, + hint: ByUpstreamHint{ + projectID: tctx.dbProj.ID, + providerID: tctx.ghAppProvider.ID, + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: "test-repo", + ProjectID: tctx.dbProj.ID, + ProviderID: tctx.ghAppProvider.ID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "123", + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + }, + checkResult: func(t *testing.T, result *models.EntityWithProperties) { + t.Helper() + require.NotNil(t, result) + require.Equal(t, "test-repo", result.Entity.Name) + require.Equal(t, "123", result.Properties.GetProperty(properties.PropertyUpstreamID).GetString()) + }, + }, + { + name: "Successful retrieval by name with project and provider ID hints", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyName: "test-repo-byname"}, + hint: ByUpstreamHint{ + projectID: tctx.dbProj.ID, + providerID: tctx.ghAppProvider.ID, + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: "test-repo-byname", + ProjectID: tctx.dbProj.ID, + ProviderID: tctx.ghAppProvider.ID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "124", + properties.PropertyName: "test-repo-byname", + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + }, + checkResult: func(t *testing.T, result *models.EntityWithProperties) { + t.Helper() + require.NotNil(t, result) + require.Equal(t, "test-repo-byname", result.Entity.Name) + require.Equal(t, "124", result.Properties.GetProperty(properties.PropertyUpstreamID).GetString()) + }, + }, + { + name: "Entity not found", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "456"}, + hint: ByUpstreamHint{ + projectID: tctx.dbProj.ID, + providerID: tctx.ghAppProvider.ID, + }, + expectedError: ErrEntityNotFound, + }, + { + name: "Multiple entities returned by ID, no provider hint", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "789"}, + hint: ByUpstreamHint{ + // since both providers are located in the same project this hint is next to useless + projectID: tctx.dbProj.ID, + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + providerIDs := []uuid.UUID{tctx.ghAppProvider.ID, tctx.dockerHubProvider.ID} + for i, providerID := range providerIDs { + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: fmt.Sprintf("test-repo-%d", i), + ProjectID: tctx.dbProj.ID, + ProviderID: providerID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "789", + properties.PropertyName: fmt.Sprintf("test-repo-%d", i), + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + } + }, + expectedError: ErrMultipleEntities, + }, + { + name: "Multiple entities returned, provider implements hint", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "890"}, + hint: ByUpstreamHint{ + ProviderImplements: db.NullProviderType{ + ProviderType: db.ProviderTypeGithub, + Valid: true, + }, + // since both providers are located in the same project this hint is next to useless + projectID: tctx.dbProj.ID, + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + providerIDs := []uuid.UUID{tctx.ghAppProvider.ID, tctx.dockerHubProvider.ID} + for i, providerID := range providerIDs { + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: fmt.Sprintf("test-repo-implements-hint-%d", i), + ProjectID: tctx.dbProj.ID, + ProviderID: providerID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "890", + properties.PropertyName: fmt.Sprintf("test-repo-implements-hint-%d", i), + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + } + }, + checkResult: func(t *testing.T, result *models.EntityWithProperties) { + t.Helper() + require.NotNil(t, result) + require.Equal(t, "test-repo-implements-hint-0", result.Entity.Name) + require.Equal(t, "890", result.Properties.GetProperty(properties.PropertyUpstreamID).GetString()) + require.Equal(t, tctx.ghAppProvider.ID, result.Entity.ProviderID) + }, + }, + { + name: "Multiple entities returned, provider hint does not match", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "891"}, + hint: ByUpstreamHint{ + ProviderImplements: db.NullProviderType{ + ProviderType: db.ProviderTypeRest, + Valid: true, + }, + // since both providers are located in the same project this hint is next to useless + projectID: tctx.dbProj.ID, + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + providerIDs := []uuid.UUID{tctx.ghAppProvider.ID, tctx.dockerHubProvider.ID} + for i, providerID := range providerIDs { + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: fmt.Sprintf("test-repo-implements-hint-nomatch-%d", i), + ProjectID: tctx.dbProj.ID, + ProviderID: providerID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "891", + properties.PropertyName: fmt.Sprintf("test-repo-implements-hint-nomatch-%d", i), + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + } + }, + expectedError: ErrEntityNotFound, + }, + { + name: "Multiple entities returned, provider ID hint", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "892"}, + hint: ByUpstreamHint{ + providerID: uuid.New(), + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + providerIDs := []uuid.UUID{tctx.ghAppProvider.ID, tctx.dockerHubProvider.ID} + for i, providerID := range providerIDs { + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: fmt.Sprintf("test-repo-bad-prov-id-hint-%d", i), + ProjectID: tctx.dbProj.ID, + ProviderID: providerID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "892", + properties.PropertyName: fmt.Sprintf("test-repo-bad-prov-id-hint-%d", i), + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + } + }, + expectedError: ErrEntityNotFound, + }, + { + name: "Property hint mismatch", + entType: minderv1.Entity_ENTITY_REPOSITORIES, + byPropMap: map[string]any{properties.PropertyUpstreamID: "101112"}, + hint: ByUpstreamHint{ + projectID: tctx.dbProj.ID, + providerID: tctx.ghAppProvider.ID, + PropName: properties.RepoPropertyIsPrivate, + PropValue: true, + }, + dbSetup: func(t *testing.T, store db.Store) { + t.Helper() + ent, err := store.CreateEntity(ctx, db.CreateEntityParams{ + EntityType: entities.EntityTypeToDB(minderv1.Entity_ENTITY_REPOSITORIES), + Name: "test-repo-mismatch", + ProjectID: tctx.dbProj.ID, + ProviderID: tctx.ghAppProvider.ID, + }) + require.NoError(t, err) + + propMap := map[string]any{ + properties.PropertyUpstreamID: "101112", + properties.PropertyName: "test-repo-mismatch", + properties.RepoPropertyIsPrivate: false, + } + insertPropertiesFromMap(ctx, t, store, ent.ID, propMap) + }, + expectedError: ErrEntityNotFound, + }, + } + + for _, tt := range scenarios { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.dbSetup != nil { + tt.dbSetup(t, tctx.testQueries) + } + + propSvc := NewPropertiesService(tctx.testQueries) + + byProps, err := properties.NewProperties(tt.byPropMap) + require.NoError(t, err) + + result, err := propSvc.EntityWithPropertiesByUpstreamHint(ctx, tt.entType, byProps, tt.hint, nil) + + if tt.expectedError != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.expectedError) + } else { + require.NoError(t, err) + tt.checkResult(t, result) + } + }) + } +} + func TestPropertiesService_RetrieveAllProperties(t *testing.T) { t.Parallel() @@ -698,7 +988,7 @@ func TestPropertiesService_RetrieveAllProperties(t *testing.T) { }, }, { - name: "Cache hit, fetch from cache", + name: "Cache hit by name, fetch from cache", dbSetup: func(t *testing.T, store db.Store, params fetchParams) { t.Helper()