diff --git a/grype/db/v6/affected_package_store.go b/grype/db/v6/affected_package_store.go index 056e95cb66d..e990bb42300 100644 --- a/grype/db/v6/affected_package_store.go +++ b/grype/db/v6/affected_package_store.go @@ -2,7 +2,9 @@ package v6 import ( "encoding/json" + "errors" "fmt" + "regexp" "gorm.io/gorm" @@ -11,6 +13,9 @@ import ( var NoDistroSpecified = &DistroSpecifier{} var AnyDistroSpecified *DistroSpecifier +var ErrMissingDistroIdentification = errors.New("missing distro name or codename") +var ErrDistroNotPresent = errors.New("distro not present") +var ErrMultipleOSMatches = errors.New("multiple OS matches found but not allowed") type GetAffectedPackageOptions struct { PreloadOS bool @@ -20,11 +25,64 @@ type GetAffectedPackageOptions struct { Distro *DistroSpecifier } +// DistroSpecifier is a struct that represents a distro in a way that can be used to query the affected package store. type DistroSpecifier struct { - Name string + // Name of the distro as identified by the ID field in /etc/os-release + Name string + + // MajorVersion is the first field in the VERSION_ID field in /etc/os-release (e.g. 7 in "7.0.1406") MajorVersion string + + // MinorVersion is the second field in the VERSION_ID field in /etc/os-release (e.g. 0 in "7.0.1406") MinorVersion string - Codename string + + // LabelVersion is mutually exclusive to MajorVersion and MinorVersion and tends to represent the + // VERSION_ID when it is not a version number (e.g. "edge" or "unstable") + LabelVersion string + + // Codename is the CODENAME field in /etc/os-release (e.g. "wheezy" for debian 7) + Codename string + + // AllowMultiple specifies whether we intend to allow for multiple distro identities to be matched. + AllowMultiple bool +} + +func (d DistroSpecifier) version() string { + if d.MajorVersion != "" && d.MinorVersion != "" { + return d.MajorVersion + "." + d.MinorVersion + } + + if d.MajorVersion != "" { + return d.MajorVersion + } + + if d.LabelVersion != "" { + return d.LabelVersion + } + + if d.Codename != "" { + return d.Codename + } + + return "" +} + +func (d DistroSpecifier) matchesVersionPattern(pattern string) bool { + // check if version or version label matches the given regex + r, err := regexp.Compile(pattern) + if err != nil { + log.Tracef("failed to compile distro specifier regex pattern %q: %v", pattern, err) + return false + } + + if r.MatchString(d.version()) { + return true + } + + if d.LabelVersion != "" { + return r.MatchString(d.LabelVersion) + } + return false } type AffectedPackageStoreWriter interface { @@ -90,7 +148,7 @@ func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, con query = query.Where("operating_system_id IS NULL") } - query = s.handlePacakge(query, packageName, config) + query = s.handlePackage(query, packageName, config) query = s.handlePreload(query, config) err := query.Find(&pkgs).Error @@ -112,15 +170,31 @@ func (s *affectedPackageStore) getNonDistroPackageByName(packageName string, con } func (s *affectedPackageStore) getPackageByNameAndDistro(packageName string, config GetAffectedPackageOptions) ([]AffectedPackageHandle, error) { + var resolvedDistros []OperatingSystem + var err error + if config.Distro != NoDistroSpecified || config.Distro != AnyDistroSpecified { + resolvedDistros, err = s.resolveDistro(*config.Distro) + if err != nil { + return nil, fmt.Errorf("unable to resolve distro: %w", err) + } + + switch { + case len(resolvedDistros) == 0: + return nil, ErrDistroNotPresent + case len(resolvedDistros) > 1 && !config.Distro.AllowMultiple: + return nil, ErrMultipleOSMatches + } + } + var pkgs []AffectedPackageHandle query := s.db.Joins("JOIN packages ON affected_package_handles.package_id = packages.id"). Joins("JOIN operating_systems ON affected_package_handles.operating_system_id = operating_systems.id") - query = s.handlePacakge(query, packageName, config) - query = s.handleDistro(query, config.Distro) + query = s.handlePackage(query, packageName, config) + query = s.handleDistros(query, resolvedDistros) query = s.handlePreload(query, config) - err := query.Find(&pkgs).Error + err = query.Find(&pkgs).Error if err != nil { return nil, fmt.Errorf("unable to fetch affected package record: %w", err) } @@ -137,34 +211,170 @@ func (s *affectedPackageStore) getPackageByNameAndDistro(packageName string, con return pkgs, nil } -func (s *affectedPackageStore) handlePacakge(query *gorm.DB, packageName string, config GetAffectedPackageOptions) *gorm.DB { - query = query.Where("packages.name = ?", packageName) - - if config.PackageType != "" { - query = query.Where("packages.type = ?", config.PackageType) +func (s *affectedPackageStore) resolveDistro(d DistroSpecifier) ([]OperatingSystem, error) { + if d.Name == "" && d.Codename == "" { + return nil, ErrMissingDistroIdentification } - return query -} -func (s *affectedPackageStore) handleDistro(query *gorm.DB, d *DistroSpecifier) *gorm.DB { - if d == AnyDistroSpecified { - return query + // search for aliases for the given distro; we intentionally map some OSs to other OSs in terms of + // vulnerability (e.g. `centos` is an alias for `rhel`). If an alias is found always use that alias in + // searches (there will never be anything in the DB for aliased distros). + if err := s.applyAlias(&d); err != nil { + return nil, err } + query := s.db.Model(&OperatingSystem{}) + if d.Name != "" { - query = query.Where("operating_systems.name = ?", d.Name) + query = query.Where("name = ?", d.Name) } if d.Codename != "" { - query = query.Where("operating_systems.codename = ?", d.Codename) + query = query.Where("codename = ?", d.Codename) + } + + if d.LabelVersion != "" { + query = query.Where("label_version = ?", d.LabelVersion) + } + + return s.searchForDistroVersionVariants(query, d) +} + +func (s *affectedPackageStore) searchForDistroVersionVariants(query *gorm.DB, d DistroSpecifier) ([]OperatingSystem, error) { + var allOs []OperatingSystem + + handleQuery := func(q *gorm.DB, desc string) ([]OperatingSystem, error) { + err := q.Find(&allOs).Error + if err == nil { + return allOs, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to query distro by %s: %w", desc, err) + } + return nil, nil + } + + if d.MajorVersion == "" && d.MinorVersion == "" { + return handleQuery(query, "name and codename only") } + // search by the most specific criteria first, then fallback + + var result []OperatingSystem + var err error if d.MajorVersion != "" { - query = query.Where("operating_systems.major_version = ?", d.MajorVersion) + if d.MinorVersion != "" { + // non-empty major and minor versions + specificQuery := query.Session(&gorm.Session{}).Where("major_version = ? AND minor_version = ?", d.MajorVersion, d.MinorVersion) + result, err = handleQuery(specificQuery, "major and minor versions") + if err != nil || len(result) > 0 { + return result, err + } + } + + // fallback to major version only, requiring the minor version to be blank. Note: it is important that we don't + // match on any record with the given major version, we must only match on records that are intentionally empty + // minor version. For instance, the DB may have rhel 8.1, 8.2, 8.3, 8.4, etc. We don't want to arbitrarily match + // on one of these or match even the latest version, as even that may yield incorrect vulnerability matching + // results. We are only intending to allow matches for when the vulnerability data is only specified at the major version level. + majorExclusiveQuery := query.Session(&gorm.Session{}).Where("major_version = ? AND minor_version = ?", d.MajorVersion, "") + result, err = handleQuery(majorExclusiveQuery, "exclusively major version") + if err != nil || len(result) > 0 { + return result, err + } + + // fallback to major version for any minor version + majorQuery := query.Session(&gorm.Session{}).Where("major_version = ?", d.MajorVersion) + result, err = handleQuery(majorQuery, "major version with any minor version") + if err != nil || len(result) > 0 { + return result, err + } + } + + return allOs, nil +} + +func (s *affectedPackageStore) applyAlias(d *DistroSpecifier) error { + if d.Name == "" { + return nil + } + + var aliases []OperatingSystemAlias + err := s.db.Where("name = ?", d.Name).Find(&aliases).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to resolve alias for distro %q: %w", d.Name, err) + } + return nil + } + + var alias *OperatingSystemAlias + + for _, a := range aliases { + if a.Codename != "" && a.Codename != d.Codename { + continue + } + + if a.Version != "" && a.Version != d.version() { + continue + } + + if a.VersionPattern != "" && !d.matchesVersionPattern(a.VersionPattern) { + continue + } + + alias = &a + break } - if d.MinorVersion != "" { - query = query.Where("operating_systems.minor_version = ?", d.MinorVersion) + if alias == nil { + return nil + } + + if alias.ReplacementName != nil { + d.Name = *alias.ReplacementName + } + + if alias.Rolling { + d.MajorVersion = "" + d.MinorVersion = "" + } + + if alias.ReplacementMajorVersion != nil { + d.MajorVersion = *alias.ReplacementMajorVersion + } + + if alias.ReplacementMinorVersion != nil { + d.MinorVersion = *alias.ReplacementMinorVersion + } + + if alias.ReplacementLabelVersion != nil { + d.LabelVersion = *alias.ReplacementLabelVersion + } + + return nil +} + +func (s *affectedPackageStore) handlePackage(query *gorm.DB, packageName string, config GetAffectedPackageOptions) *gorm.DB { + query = query.Where("packages.name = ?", packageName) + + if config.PackageType != "" { + query = query.Where("packages.type = ?", config.PackageType) + } + return query +} + +func (s *affectedPackageStore) handleDistros(query *gorm.DB, resolvedDistros []OperatingSystem) *gorm.DB { + var count int + for _, o := range resolvedDistros { + if o.ID != 0 { + if count == 0 { + query = query.Where("operating_systems.id = ?", o.ID) + } else { + query = query.Or("operating_systems.id = ?", o.ID) + } + count++ + } } return query } diff --git a/grype/db/v6/affected_package_store_test.go b/grype/db/v6/affected_package_store_test.go index 689285d7507..a73ecc34259 100644 --- a/grype/db/v6/affected_package_store_test.go +++ b/grype/db/v6/affected_package_store_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -57,6 +58,7 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { packageName string options *GetAffectedPackageOptions expected []AffectedPackageHandle + wantErr require.ErrorAssertionFunc }{ { name: "specific distro", @@ -71,16 +73,29 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { expected: []AffectedPackageHandle{*pkg2d1}, }, { - name: "distro major version", + name: "distro major version only (allow multiple)", packageName: pkg2d1.Package.Name, options: &GetAffectedPackageOptions{ Distro: &DistroSpecifier{ - Name: "ubuntu", - MajorVersion: "20", + Name: "ubuntu", + MajorVersion: "20", + AllowMultiple: true, }, }, expected: []AffectedPackageHandle{*pkg2d1, *pkg2d2}, }, + { + name: "distro major version only (default)", + packageName: pkg2d1.Package.Name, + options: &GetAffectedPackageOptions{ + Distro: &DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + AllowMultiple: false, + }, + }, + wantErr: expectErrIs(t, ErrMultipleOSMatches), + }, { name: "distro codename", packageName: pkg2d1.Package.Name, @@ -203,9 +218,12 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - + if tt.wantErr == nil { + tt.wantErr = require.NoError + } for _, pc := range preloadCases { t.Run(pc.name, func(t *testing.T) { + opts := tt.options opts.PreloadOS = pc.PreloadOS opts.PreloadPackage = pc.PreloadPackage @@ -215,7 +233,10 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { expected = pc.prepExpectations(t, expected) } result, err := s.GetAffectedPackagesByName(tt.packageName, opts) - require.NoError(t, err) + tt.wantErr(t, err) + if err != nil { + return + } if d := cmp.Diff(expected, result); d != "" { t.Errorf(fmt.Sprintf("unexpected result: %s", d)) } @@ -225,6 +246,217 @@ func TestAffectedPackageStore_GetAffectedPackagesByName(t *testing.T) { } } +func TestAffectedPackageStore_ResolveDistro(t *testing.T) { + db := setupTestStore(t).db + bs := newBlobStore(db) + s := newAffectedPackageStore(db, bs) + + aliases := []OperatingSystemAlias{ + {Name: "centos", ReplacementName: strRef("rhel")}, + {Name: "rocky", ReplacementName: strRef("rhel")}, + {Name: "alpine", VersionPattern: ".*_alpha.*", ReplacementLabelVersion: strRef("edge"), Rolling: true}, + {Name: "wolfi", Rolling: true}, + {Name: "arch", Rolling: true}, + {Name: "debian", Codename: "trixie", Rolling: true}, // is currently sid, which is considered rolling + } + require.NoError(t, db.Create(&aliases).Error) + + ubuntu2004 := &OperatingSystem{Name: "ubuntu", MajorVersion: "20", MinorVersion: "04", Codename: "focal"} + ubuntu2010 := &OperatingSystem{Name: "ubuntu", MajorVersion: "20", MinorVersion: "10", Codename: "groovy"} + rhel8 := &OperatingSystem{Name: "rhel", MajorVersion: "8"} + rhel81 := &OperatingSystem{Name: "rhel", MajorVersion: "8", MinorVersion: "1"} + debian10 := &OperatingSystem{Name: "debian", MajorVersion: "10"} + alpine318 := &OperatingSystem{Name: "alpine", MajorVersion: "3", MinorVersion: "18"} + alpineEdge := &OperatingSystem{Name: "alpine", LabelVersion: "edge"} + debianTrixie := &OperatingSystem{Name: "debian", Codename: "trixie"} + debian7 := &OperatingSystem{Name: "debian", MajorVersion: "7", Codename: "wheezy"} + wolfi := &OperatingSystem{Name: "wolfi", MajorVersion: "20230201"} + arch := &OperatingSystem{Name: "arch", MajorVersion: "20241110", MinorVersion: "0"} + + operatingSystems := []*OperatingSystem{ + ubuntu2004, + ubuntu2010, + rhel8, + rhel81, + debian10, + alpine318, + alpineEdge, + debianTrixie, + debian7, + wolfi, + arch, + } + require.NoError(t, db.Create(&operatingSystems).Error) + + tests := []struct { + name string + distro DistroSpecifier + expected []OperatingSystem + expectErr require.ErrorAssertionFunc + }{ + { + name: "specific distro with major and minor version", + distro: DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + }, + expected: []OperatingSystem{*ubuntu2004}, + }, + { + name: "alias resolution with major version", + distro: DistroSpecifier{ + Name: "centos", + MajorVersion: "8", + }, + expected: []OperatingSystem{*rhel8}, + }, + { + name: "alias resolution with major and minor version", + distro: DistroSpecifier{ + Name: "centos", + MajorVersion: "8", + MinorVersion: "1", + }, + expected: []OperatingSystem{*rhel81}, + }, + { + name: "distro with major version only", + distro: DistroSpecifier{ + Name: "debian", + MajorVersion: "10", + }, + expected: []OperatingSystem{*debian10}, + }, + { + name: "codename resolution", + distro: DistroSpecifier{ + Name: "ubuntu", + Codename: "focal", + }, + expected: []OperatingSystem{*ubuntu2004}, + }, + { + name: "codename and version info", + distro: DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + Codename: "focal", + }, + expected: []OperatingSystem{*ubuntu2004}, + }, + { + name: "conflicting codename and version info", + distro: DistroSpecifier{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + Codename: "fake", + }, + }, + { + name: "alpine edge version", + distro: DistroSpecifier{ + Name: "alpine", + MajorVersion: "3", + MinorVersion: "21", + LabelVersion: "3.21.0_alpha20240807", + }, + expected: []OperatingSystem{*alpineEdge}, + }, + { + name: "arch rolling variant", + distro: DistroSpecifier{ + Name: "arch", + }, + expected: []OperatingSystem{*arch}, + }, + { + name: "wolfi rolling variant", + distro: DistroSpecifier{ + Name: "wolfi", + MajorVersion: "20221018", + }, + expected: []OperatingSystem{*wolfi}, + }, + { + name: "debian by codename for rolling alias", + distro: DistroSpecifier{ + Name: "debian", + MajorVersion: "13", + Codename: "trixie", // TODO: what about sid status indication from pretty-name or /etc/debian_version? + }, + expected: []OperatingSystem{*debianTrixie}, + }, + { + name: "debian by codename", + distro: DistroSpecifier{ + Name: "debian", + Codename: "wheezy", + }, + expected: []OperatingSystem{*debian7}, + }, + { + name: "debian by major version", + distro: DistroSpecifier{ + Name: "debian", + MajorVersion: "7", + }, + expected: []OperatingSystem{*debian7}, + }, + { + name: "debian by major.minor version", + distro: DistroSpecifier{ + Name: "debian", + MajorVersion: "7", + MinorVersion: "2", + }, + expected: []OperatingSystem{*debian7}, + }, + { + name: "alpine with major and minor version", + distro: DistroSpecifier{ + Name: "alpine", + MajorVersion: "3", + MinorVersion: "18", + }, + expected: []OperatingSystem{*alpine318}, + }, + { + name: "missing distro name", + distro: DistroSpecifier{ + MajorVersion: "8", + }, + expectErr: expectErrIs(t, ErrMissingDistroIdentification), + }, + { + name: "nonexistent distro", + distro: DistroSpecifier{ + Name: "madeup", + MajorVersion: "99", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.expectErr == nil { + tt.expectErr = require.NoError + } + result, err := s.resolveDistro(tt.distro) + tt.expectErr(t, err) + if err != nil { + return + } + + if diff := cmp.Diff(tt.expected, result, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("unexpected result (-want +got):\n%s", diff) + } + }) + } +} + func TestDistroDisplay(t *testing.T) { tests := []struct { name string @@ -349,3 +581,15 @@ func testNonDistroAffectedPackage2Handle() *AffectedPackageHandle { }, } } + +func expectErrIs(t *testing.T, expected error) require.ErrorAssertionFunc { + t.Helper() + return func(t require.TestingT, err error, msgAndArgs ...interface{}) { + require.Error(t, err, msgAndArgs...) + assert.ErrorIs(t, err, expected) + } +} + +func strRef(s string) *string { + return &s +} diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go index 53d920a893d..7d4a74a287b 100644 --- a/grype/db/v6/models.go +++ b/grype/db/v6/models.go @@ -28,6 +28,7 @@ func Models() []any { // package related search tables &AffectedPackageHandle{}, // join on package, operating system &OperatingSystem{}, + &OperatingSystemAlias{}, &Package{}, // CPE related search tables @@ -150,10 +151,14 @@ type OperatingSystem struct { Name string `gorm:"column:name;index:os_idx,unique"` MajorVersion string `gorm:"column:major_version;index:os_idx,unique"` MinorVersion string `gorm:"column:minor_version;index:os_idx,unique"` + LabelVersion string `gorm:"column:label_version;index:os_idx,unique"` Codename string `gorm:"column:codename"` } func (os *OperatingSystem) BeforeCreate(tx *gorm.DB) (err error) { + if (os.MajorVersion != "" || os.MinorVersion != "") && os.LabelVersion != "" { + return fmt.Errorf("cannot have both label_version and major_version/minor_version set") + } // if the name, major version, and minor version already exist in the table then we should not insert a new record var existing OperatingSystem result := tx.Where("name = ? AND major_version = ? AND minor_version = ?", os.Name, os.MajorVersion, os.MinorVersion).First(&existing) @@ -164,6 +169,29 @@ func (os *OperatingSystem) BeforeCreate(tx *gorm.DB) (err error) { return nil } +type OperatingSystemAlias struct { + // Name is the matching name as found in the ID field if the /etc/os-release file + Name string `gorm:"column:name;primaryKey;index:os_alias_idx"` + + // Version is the matching version as found in the ID field if the /etc/os-release file + Version string `gorm:"column:version;primaryKey"` + VersionPattern string `gorm:"column:version_pattern;primaryKey"` + Codename string `gorm:"column:codename"` + ReplacementName *string `gorm:"column:replacement;primaryKey"` + ReplacementMajorVersion *string `gorm:"column:replacement_major_version;primaryKey"` + ReplacementMinorVersion *string `gorm:"column:replacement_minor_version;primaryKey"` + ReplacementLabelVersion *string `gorm:"column:replacement_label_version;primaryKey"` + Rolling bool `gorm:"column:rolling;primaryKey"` +} + +func (os *OperatingSystemAlias) BeforeCreate(_ *gorm.DB) (err error) { + if os.Version != "" && os.VersionPattern != "" { + return fmt.Errorf("cannot have both version and version_pattern set") + } + + return nil +} + // CPE related search tables ////////////////////////////////////////////////////// // AffectedCPEHandle represents a single CPE affected by the specified vulnerability diff --git a/grype/db/v6/models_test.go b/grype/db/v6/models_test.go new file mode 100644 index 00000000000..112f6c24d0b --- /dev/null +++ b/grype/db/v6/models_test.go @@ -0,0 +1,125 @@ +package v6 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOperatingSystem_LabelVersionMutualExclusivity(t *testing.T) { + msg := "cannot have both label_version and major_version/minor_version set" + db := setupTestStore(t).db + + tests := []struct { + name string + input *OperatingSystem + errMsg string + }{ + { + name: "label version and major version are mutually exclusive", + input: &OperatingSystem{ + Name: "ubuntu", + MajorVersion: "20", + LabelVersion: "something", + }, + errMsg: msg, + }, + { + name: "label version and major.minor version are mutually exclusive", + input: &OperatingSystem{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + LabelVersion: "something", + }, + errMsg: msg, + }, + { + name: "label version and minor version are mutually exclusive", + input: &OperatingSystem{ + Name: "ubuntu", + MinorVersion: "04", + LabelVersion: "something", + }, + errMsg: msg, + }, + { + name: "label version set", + input: &OperatingSystem{ + Name: "ubuntu", + LabelVersion: "something", + }, + }, + { + name: "major/minor version set", + input: &OperatingSystem{ + Name: "ubuntu", + MajorVersion: "20", + MinorVersion: "04", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := db.Create(tt.input).Error + if tt.errMsg == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestOperatingSystemAlias_VersionMutualExclusivity(t *testing.T) { + db := setupTestStore(t).db + + msg := "cannot have both version and version_pattern set" + + tests := []struct { + name string + input *OperatingSystemAlias + errMsg string + }{ + { + name: "version and version_pattern are mutually exclusive", + input: &OperatingSystemAlias{ + Name: "ubuntu", + Version: "20.04", + VersionPattern: "20.*", + }, + errMsg: msg, + }, + { + name: "only version is set", + input: &OperatingSystemAlias{ + Name: "ubuntu", + Version: "20.04", + }, + errMsg: "", + }, + { + name: "only version_pattern is set", + input: &OperatingSystemAlias{ + Name: "ubuntu", + VersionPattern: "20.*", + }, + errMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := db.Create(tt.input).Error + if tt.errMsg == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } + }) + } +}