diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index f4e3a12..a008a62 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -53,6 +53,8 @@ const ( SourceTypeArtifactsFlag = "source-type-artifacts" PropertiesFlag = "properties" DeletePropertiesFlag = "delete-properties" + IncludeFilterFlag = "include-filter" + ExcludeFilterFlag = "exclude-filter" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -85,6 +87,8 @@ var flagsMap = map[string]components.Flag{ SourceTypeReleaseBundlesFlag: components.NewStringFlag(SourceTypeReleaseBundlesFlag, "List of semicolon-separated (;) release bundles in the form of 'name=releaseBundleName1, version=version1[, project-key=project1][, repo-key=repo1]; name=releaseBundleName2, version=version2[, project-key=project2][, repo-key=repo2]' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), SourceTypeApplicationVersionsFlag: components.NewStringFlag(SourceTypeApplicationVersionsFlag, "List of semicolon-separated (;) application versions in the form of 'application-key=app1, version=version1; application-key=app2, version=version2' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), SourceTypePackagesFlag: components.NewStringFlag(SourceTypePackagesFlag, "List of semicolon-separated (;) packages in the form of 'type=packageType1, name=packageName1, version=version1, repo-key=repo1; type=packageType2, name=packageName2, version=version2, repo-key=repo2' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), + IncludeFilterFlag: components.NewStringFlag(IncludeFilterFlag, "List of semicolon-separated (;) filters of packages and artifacts in the form of 'filter1; filter2...' to be included in the new version. Each filter must be comma-separated: 'filter_type=package/artifact, field1=value1[, field2=value2...]'. Package filters require at least one of: 'type', 'name', or 'version'. Artifact filters require at least one of: 'path' or 'sha256'.", func(f *components.StringFlag) { f.Mandatory = false }), + ExcludeFilterFlag: components.NewStringFlag(ExcludeFilterFlag, "List of semicolon-separated (;) filters of packages and artifacts in the form of 'filter1; filter2...' to be included in the new version. Each filter must be comma-separated: 'filter_type=package/artifact, field1=value1[, field2=value2...]'. Package filters require at least one of: 'type', 'name', or 'version'. Artifact filters require at least one of: 'path' or 'sha256'.", func(f *components.StringFlag) { f.Mandatory = false }), SourceTypeArtifactsFlag: components.NewStringFlag(SourceTypeArtifactsFlag, "List of semicolon-separated (;) artifacts in the form of 'path=repo/path/to/artifact1[, sha256=hash1]; path=repo/path/to/artifact2[, sha256=hash2]' to be included in the new version.", func(f *components.StringFlag) { f.Mandatory = false }), PropertiesFlag: components.NewStringFlag(PropertiesFlag, "Sets or updates custom properties for the application version in format 'key1=value1[,value2,...];key2=value3[,value4,...]'", func(f *components.StringFlag) { f.Mandatory = false }), DeletePropertiesFlag: components.NewStringFlag(DeletePropertiesFlag, "Remove a property key and all its values", func(f *components.StringFlag) { f.Mandatory = false }), @@ -103,6 +107,8 @@ var commandFlags = map[string][]string{ SourceTypePackagesFlag, SourceTypeArtifactsFlag, SpecFlag, + IncludeFilterFlag, + ExcludeFilterFlag, SpecVarsFlag, }, VersionPromote: { diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index dcd6ef1..4737c32 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -34,6 +34,7 @@ type createVersionSpec struct { Builds []model.CreateVersionBuild `json:"builds,omitempty"` ReleaseBundles []model.CreateVersionReleaseBundle `json:"release_bundles,omitempty"` Versions []model.CreateVersionReference `json:"versions,omitempty"` + Filters *model.CreateVersionFilters `json:"filters,omitempty"` } func (cv *createAppVersionCommand) Run() error { @@ -72,13 +73,18 @@ func (cv *createAppVersionCommand) prepareAndRunCommand(ctx *components.Context) func (cv *createAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.CreateAppVersionRequest, error) { var ( sources *model.CreateVersionSources + filters *model.CreateVersionFilters err error ) if ctx.IsFlagSet(commands.SpecFlag) { - sources, err = cv.loadFromSpec(ctx) + sources, filters, err = cv.loadFromSpec(ctx) } else { sources, err = cv.buildSourcesFromFlags(ctx) + if err != nil { + return nil, err + } + filters, err = cv.buildFiltersFromFlags(ctx) } if err != nil { @@ -90,6 +96,7 @@ func (cv *createAppVersionCommand) buildRequestPayload(ctx *components.Context) Version: ctx.Arguments[1], Sources: sources, Tag: ctx.GetStringFlagValue(commands.TagFlag), + Filters: filters, }, nil } @@ -133,13 +140,13 @@ func (cv *createAppVersionCommand) buildSourcesFromFlags(ctx *components.Context return sources, nil } -func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model.CreateVersionSources, error) { +func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model.CreateVersionSources, *model.CreateVersionFilters, error) { specFilePath := ctx.GetStringFlagValue(commands.SpecFlag) spec := new(createVersionSpec) specVars := coreutils.SpecVarsStringToMap(ctx.GetStringFlagValue(commands.SpecVarsFlag)) content, err := fileutils.ReadFile(specFilePath) if errorutils.CheckError(err) != nil { - return nil, err + return nil, nil, err } if len(specVars) > 0 { @@ -148,12 +155,12 @@ func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model err = json.Unmarshal(content, spec) if errorutils.CheckError(err) != nil { - return nil, err + return nil, nil, err } // Validation: if all sources are empty, return error if (len(spec.Packages) == 0) && (len(spec.Builds) == 0) && (len(spec.ReleaseBundles) == 0) && (len(spec.Versions) == 0) && (len(spec.Artifacts) == 0) { - return nil, errorutils.CheckErrorf("Spec file is empty: must provide at least one source (artifacts, packages, builds, release_bundles, or versions)") + return nil, nil, errorutils.CheckErrorf("Spec file is empty: must provide at least one source (artifacts, packages, builds, release_bundles, or versions)") } sources := &model.CreateVersionSources{ @@ -164,7 +171,7 @@ func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model Versions: spec.Versions, } - return sources, nil + return sources, spec.Filters, nil } func (cv *createAppVersionCommand) parseBuilds(buildsStr string) ([]model.CreateVersionBuild, error) { @@ -310,6 +317,92 @@ func (cv *createAppVersionCommand) parseArtifacts(artifactsStr string) ([]model. return artifacts, nil } +func (cv *createAppVersionCommand) buildFiltersFromFlags(ctx *components.Context) (*model.CreateVersionFilters, error) { + includeFilterValues := ctx.GetStringsArrFlagValue(commands.IncludeFilterFlag) + excludeFilterValues := ctx.GetStringsArrFlagValue(commands.ExcludeFilterFlag) + + if len(includeFilterValues) == 0 && len(excludeFilterValues) == 0 { + return nil, nil + } + filters := &model.CreateVersionFilters{} + if includedFilters, err := cv.parseFilterValues(includeFilterValues); err != nil { + return nil, err + } else if len(includedFilters) > 0 { + filters.Included = includedFilters + } + if excludedFilters, err := cv.parseFilterValues(excludeFilterValues); err != nil { + return nil, err + } else if len(excludedFilters) > 0 { + filters.Excluded = excludedFilters + } + + return filters, nil +} + +func (cv *createAppVersionCommand) parseFilterValues(filterValues []string) ([]*model.CreateVersionSourceFilter, error) { + if len(filterValues) == 0 { + return nil, nil + } + return cv.parseFilters(filterValues) +} + +func (cv *createAppVersionCommand) parseFilters(filterStrings []string) ([]*model.CreateVersionSourceFilter, error) { + const ( + filterTypeField = "filter_type" + packageTypeField = "type" + packageNameField = "name" + packageVersionField = "version" + artifactPathField = "path" + artifactShaField = "sha256" + ) + + var filters []*model.CreateVersionSourceFilter + + for i, filterStr := range filterStrings { + filterMap, err := utils.ParseKeyValueString(filterStr, ",") + if err != nil { + return nil, errorutils.CheckErrorf("invalid filter format at index %d: %v", i, err) + } + filterType, ok := filterMap[filterTypeField] + if !ok { + return nil, errorutils.CheckErrorf("invalid filter format at index %d: missing 'filter_type' field", i) + } + filter := &model.CreateVersionSourceFilter{} + + switch filterType { + case "package": + if val, ok := filterMap[packageTypeField]; ok { + filter.PackageType = val + } + if val, ok := filterMap[packageNameField]; ok { + filter.PackageName = val + } + if val, ok := filterMap[packageVersionField]; ok { + filter.PackageVersion = val + } + if filter.PackageType == "" && filter.PackageName == "" && filter.PackageVersion == "" { + return nil, errorutils.CheckErrorf("invalid package filter at index %d: at least one of 'type', 'name', or 'version' must be specified", i) + } + case "artifact": + if val, ok := filterMap[artifactPathField]; ok { + filter.Path = val + } + if val, ok := filterMap[artifactShaField]; ok { + filter.SHA256 = val + } + if filter.Path == "" && filter.SHA256 == "" { + return nil, errorutils.CheckErrorf("invalid artifact filter at index %d: at least one of 'path' or 'sha256' must be specified", i) + } + default: + return nil, errorutils.CheckErrorf("invalid filter_type '%s' at index %d: must be 'package' or 'artifact'", filterType, i) + } + + filters = append(filters, filter) + } + + return filters, nil +} + func validateCreateAppVersionContext(ctx *components.Context) error { if err := validateNoSpecAndFlagsTogether(ctx); err != nil { return err @@ -358,7 +451,7 @@ func GetCreateAppVersionCommand(appContext app.Context) components.Command { } } -// Returns error if both --spec and any other source flag are set +// Returns error if both --spec and any other source flag or filter flag are set func validateNoSpecAndFlagsTogether(ctx *components.Context) error { if ctx.IsFlagSet(commands.SpecFlag) { otherSourceFlags := []string{ @@ -373,6 +466,12 @@ func validateNoSpecAndFlagsTogether(ctx *components.Context) error { return errorutils.CheckErrorf("--spec provided: all other source flags (e.g., --%s) are not allowed.", flag) } } + if ctx.IsFlagSet(commands.IncludeFilterFlag) { + return errorutils.CheckErrorf("--spec provided: filter flags (e.g., --%s) are not allowed.", commands.IncludeFilterFlag) + } + if ctx.IsFlagSet(commands.ExcludeFilterFlag) { + return errorutils.CheckErrorf("--spec provided: filter flags (e.g., --%s) are not allowed.", commands.ExcludeFilterFlag) + } } return nil } diff --git a/apptrust/commands/version/create_app_version_cmd_test.go b/apptrust/commands/version/create_app_version_cmd_test.go index ace3820..7fd9842 100644 --- a/apptrust/commands/version/create_app_version_cmd_test.go +++ b/apptrust/commands/version/create_app_version_cmd_test.go @@ -884,6 +884,81 @@ func TestValidateNoSpecAndFlagsTogether(t *testing.T) { } } +func TestValidateNoSpecAndFlagsTogether_WithFilterFlags(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectError bool + errorContains string + }{ + { + name: "spec with include filter flag - should error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker, name=frontend-*") + }, + expectError: true, + errorContains: "filter flags", + }, + { + name: "spec with exclude filter flag - should error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.ExcludeFilterFlag, "filter_type=package, name=*-dev") + }, + expectError: true, + errorContains: "filter flags", + }, + { + name: "spec with both filter flags - should error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker") + ctx.AddStringFlag(commands.ExcludeFilterFlag, "filter_type=package, name=*-dev") + }, + expectError: true, + errorContains: "filter flags", + }, + { + name: "spec without filter flags - should not error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + }, + expectError: false, + }, + { + name: "no spec with filter flags - should not error", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SourceTypePackagesFlag, "type=npm,name=test,version=1.0.0,repo-key=repo") + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker") + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + tt.ctxSetup(ctx) + + err := validateNoSpecAndFlagsTogether(ctx) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + func TestValidateRequiredFieldsInMap(t *testing.T) { tests := []struct { name string @@ -928,3 +1003,486 @@ func TestValidateRequiredFieldsInMap(t *testing.T) { }) } } + +func TestParseFilters(t *testing.T) { + cmd := &createAppVersionCommand{} + + tests := []struct { + name string + input []string + expectError bool + errorContains string + expectedFilters []*model.CreateVersionSourceFilter + }{ + { + name: "package filter with type and name", + input: []string{"filter_type=package, type=docker, name=frontend-*"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {PackageType: "docker", PackageName: "frontend-*"}, + }, + }, + { + name: "package filter with all fields", + input: []string{"filter_type=package, type=npm, name=my-package, version=1.0.0"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {PackageType: "npm", PackageName: "my-package", PackageVersion: "1.0.0"}, + }, + }, + { + name: "package filter with only name", + input: []string{"filter_type=package, name=*-dev"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {PackageName: "*-dev"}, + }, + }, + { + name: "package filter with only version", + input: []string{"filter_type=package, version=3.*"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {PackageVersion: "3.*"}, + }, + }, + { + name: "artifact filter with path", + input: []string{"filter_type=artifact, path=libs/*.jar"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {Path: "libs/*.jar"}, + }, + }, + { + name: "artifact filter with path and sha256", + input: []string{"filter_type=artifact, path=libs/artifact.jar, sha256=a1b2c3d4e5f6"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {Path: "libs/artifact.jar", SHA256: "a1b2c3d4e5f6"}, + }, + }, + { + name: "artifact filter with only sha256", + input: []string{"filter_type=artifact, sha256=a1b2c3d4e5f6789012345678901234567890123456789012345678901267890"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {SHA256: "a1b2c3d4e5f6789012345678901234567890123456789012345678901267890"}, + }, + }, + { + name: "multiple filters - package and artifact", + input: []string{"filter_type=package, type=docker, name=frontend-*", "filter_type=artifact, path=libs/*.jar"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {PackageType: "docker", PackageName: "frontend-*"}, + {Path: "libs/*.jar"}, + }, + }, + { + name: "missing filter_type", + input: []string{"type=docker, name=frontend-*"}, + expectError: true, + errorContains: "missing 'filter_type' field", + }, + { + name: "invalid filter_type", + input: []string{"filter_type=invalid, type=docker"}, + expectError: true, + errorContains: "invalid filter_type 'invalid'", + }, + { + name: "package filter with no fields", + input: []string{"filter_type=package"}, + expectError: true, + errorContains: "at least one of 'type', 'name', or 'version' must be specified", + }, + { + name: "artifact filter with no fields", + input: []string{"filter_type=artifact"}, + expectError: true, + errorContains: "at least one of 'path' or 'sha256' must be specified", + }, + { + name: "invalid format - missing equals", + input: []string{"filter_type=package type=docker"}, + expectError: true, + errorContains: "invalid filter_type", + }, + { + name: "empty input", + input: []string{}, + expectedFilters: []*model.CreateVersionSourceFilter{}, + }, + { + name: "multiple package filters", + input: []string{"filter_type=package, type=docker, name=frontend-*", "filter_type=package, version=3.*"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {PackageType: "docker", PackageName: "frontend-*"}, + {PackageVersion: "3.*"}, + }, + }, + { + name: "multiple artifact filters", + input: []string{"filter_type=artifact, path=libs/*.jar", "filter_type=artifact, path=libs/vulnerable-lib-1.2.3.jar"}, + expectedFilters: []*model.CreateVersionSourceFilter{ + {Path: "libs/*.jar"}, + {Path: "libs/vulnerable-lib-1.2.3.jar"}, + }, + }, + { + name: "error in second filter", + input: []string{"filter_type=package, type=docker, name=frontend-*", "filter_type=package"}, + expectError: true, + errorContains: "at least one of 'type', 'name', or 'version' must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filters, err := cmd.parseFilters(tt.input) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, len(tt.expectedFilters), len(filters)) + for i, expected := range tt.expectedFilters { + assert.Equal(t, expected.PackageType, filters[i].PackageType) + assert.Equal(t, expected.PackageName, filters[i].PackageName) + assert.Equal(t, expected.PackageVersion, filters[i].PackageVersion) + assert.Equal(t, expected.Path, filters[i].Path) + assert.Equal(t, expected.SHA256, filters[i].SHA256) + } + } + }) + } +} + +func TestBuildFiltersFromFlags(t *testing.T) { + cmd := &createAppVersionCommand{} + + tests := []struct { + name string + ctxSetup func(*components.Context) + expectError bool + errorContains string + expectedFilters *model.CreateVersionFilters + }{ + { + name: "no filters", + ctxSetup: func(ctx *components.Context) { + }, + expectedFilters: nil, + }, + { + name: "single include filter", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker, name=frontend-*") + }, + expectedFilters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + {PackageType: "docker", PackageName: "frontend-*"}, + }, + }, + }, + { + name: "single exclude filter", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.ExcludeFilterFlag, "filter_type=package, name=*-dev") + }, + expectedFilters: &model.CreateVersionFilters{ + Excluded: []*model.CreateVersionSourceFilter{ + {PackageName: "*-dev"}, + }, + }, + }, + { + name: "include and exclude filters", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker, name=frontend-*") + ctx.AddStringFlag(commands.ExcludeFilterFlag, "filter_type=package, name=*-dev") + }, + expectedFilters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + {PackageType: "docker", PackageName: "frontend-*"}, + }, + Excluded: []*model.CreateVersionSourceFilter{ + {PackageName: "*-dev"}, + }, + }, + }, + { + name: "multiple include and exclude filters", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker, name=frontend-*; filter_type=artifact, path=libs/*.jar") + ctx.AddStringFlag(commands.ExcludeFilterFlag, "filter_type=package, name=*-dev; filter_type=package, name=*versions*") + }, + expectedFilters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + {PackageType: "docker", PackageName: "frontend-*"}, + {Path: "libs/*.jar"}, + }, + Excluded: []*model.CreateVersionSourceFilter{ + {PackageName: "*-dev"}, + {PackageName: "*versions*"}, + }, + }, + }, + { + name: "invalid include filter", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package") + }, + expectError: true, + errorContains: "at least one of 'type', 'name', or 'version' must be specified", + }, + { + name: "invalid exclude filter", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.ExcludeFilterFlag, "filter_type=artifact") + }, + expectError: true, + errorContains: "at least one of 'path' or 'sha256' must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + tt.ctxSetup(ctx) + + filters, err := cmd.buildFiltersFromFlags(ctx) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + if tt.expectedFilters == nil { + assert.Nil(t, filters) + } else { + assert.NotNil(t, filters) + if tt.expectedFilters.Included != nil { + assert.Equal(t, len(tt.expectedFilters.Included), len(filters.Included)) + for i, expected := range tt.expectedFilters.Included { + assert.Equal(t, expected.PackageType, filters.Included[i].PackageType) + assert.Equal(t, expected.PackageName, filters.Included[i].PackageName) + assert.Equal(t, expected.PackageVersion, filters.Included[i].PackageVersion) + assert.Equal(t, expected.Path, filters.Included[i].Path) + assert.Equal(t, expected.SHA256, filters.Included[i].SHA256) + } + } + if tt.expectedFilters.Excluded != nil { + assert.Equal(t, len(tt.expectedFilters.Excluded), len(filters.Excluded)) + for i, expected := range tt.expectedFilters.Excluded { + assert.Equal(t, expected.PackageType, filters.Excluded[i].PackageType) + assert.Equal(t, expected.PackageName, filters.Excluded[i].PackageName) + assert.Equal(t, expected.PackageVersion, filters.Excluded[i].PackageVersion) + assert.Equal(t, expected.Path, filters.Excluded[i].Path) + assert.Equal(t, expected.SHA256, filters.Excluded[i].SHA256) + } + } + } + } + }) + } +} + +func TestLoadFromSpec_WithFilters(t *testing.T) { + cmd := &createAppVersionCommand{} + + tests := []struct { + name string + specPath string + expectError bool + errorContains string + expectedSources *model.CreateVersionSources + expectedFilters *model.CreateVersionFilters + }{ + { + name: "spec with included filters", + specPath: "./testfiles/filters-spec.json", + expectedSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + { + Type: "npm", + Name: "pkg-with-filters", + Version: "1.0.0", + Repository: "repo-filters", + }, + }, + }, + expectedFilters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + { + PackageType: "docker", + PackageName: "frontend-*", + }, + }, + Excluded: []*model.CreateVersionSourceFilter{ + { + Path: "libs/vulnerable-*.jar", + }, + }, + }, + }, + { + name: "spec without filters", + specPath: "./testfiles/minimal-spec.json", + expectedSources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + { + Type: "npm", + Name: "pkg-min", + Version: "0.1.0", + Repository: "repo-min", + }, + }, + }, + expectedFilters: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + ctx.AddStringFlag(commands.SpecFlag, tt.specPath) + ctx.AddStringFlag("url", "https://example.com") + + sources, filters, err := cmd.loadFromSpec(ctx) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedSources, sources) + if tt.expectedFilters == nil { + assert.Nil(t, filters) + } else { + assert.NotNil(t, filters) + if tt.expectedFilters.Included != nil { + assert.Equal(t, len(tt.expectedFilters.Included), len(filters.Included)) + for i, expected := range tt.expectedFilters.Included { + assert.Equal(t, expected.PackageType, filters.Included[i].PackageType) + assert.Equal(t, expected.PackageName, filters.Included[i].PackageName) + assert.Equal(t, expected.PackageVersion, filters.Included[i].PackageVersion) + assert.Equal(t, expected.Path, filters.Included[i].Path) + assert.Equal(t, expected.SHA256, filters.Included[i].SHA256) + } + } + if tt.expectedFilters.Excluded != nil { + assert.Equal(t, len(tt.expectedFilters.Excluded), len(filters.Excluded)) + for i, expected := range tt.expectedFilters.Excluded { + assert.Equal(t, expected.PackageType, filters.Excluded[i].PackageType) + assert.Equal(t, expected.PackageName, filters.Excluded[i].PackageName) + assert.Equal(t, expected.PackageVersion, filters.Excluded[i].PackageVersion) + assert.Equal(t, expected.Path, filters.Excluded[i].Path) + assert.Equal(t, expected.SHA256, filters.Excluded[i].SHA256) + } + } + } + } + }) + } +} + +func TestBuildRequestPayload_Filters(t *testing.T) { + cmd := &createAppVersionCommand{} + + tests := []struct { + name string + ctxSetup func(*components.Context) + expectError bool + errorContains string + expectedFilters *model.CreateVersionFilters + }{ + { + name: "filters from spec when spec flag is set", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/filters-spec.json") + ctx.AddStringFlag("url", "https://example.com") + }, + expectedFilters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + { + PackageType: "docker", + PackageName: "frontend-*", + }, + }, + Excluded: []*model.CreateVersionSourceFilter{ + { + Path: "libs/vulnerable-*.jar", + }, + }, + }, + }, + { + name: "filters from flags when spec flag is not set", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SourceTypePackagesFlag, "type=npm,name=test,version=1.0.0,repo-key=repo") + ctx.AddStringFlag(commands.IncludeFilterFlag, "filter_type=package, type=docker, name=frontend-*") + ctx.AddStringFlag("url", "https://example.com") + }, + expectedFilters: &model.CreateVersionFilters{ + Included: []*model.CreateVersionSourceFilter{ + { + PackageType: "docker", + PackageName: "frontend-*", + }, + }, + }, + }, + { + name: "no filters when spec has no filters and no filter flags", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "./testfiles/minimal-spec.json") + ctx.AddStringFlag("url", "https://example.com") + }, + expectedFilters: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + tt.ctxSetup(ctx) + + payload, err := cmd.buildRequestPayload(ctx) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, payload) + if tt.expectedFilters == nil { + assert.Nil(t, payload.Filters) + } else { + assert.NotNil(t, payload.Filters) + if tt.expectedFilters.Included != nil { + assert.Equal(t, len(tt.expectedFilters.Included), len(payload.Filters.Included)) + for i, expected := range tt.expectedFilters.Included { + assert.Equal(t, expected.PackageType, payload.Filters.Included[i].PackageType) + assert.Equal(t, expected.PackageName, payload.Filters.Included[i].PackageName) + assert.Equal(t, expected.PackageVersion, payload.Filters.Included[i].PackageVersion) + assert.Equal(t, expected.Path, payload.Filters.Included[i].Path) + assert.Equal(t, expected.SHA256, payload.Filters.Included[i].SHA256) + } + } + if tt.expectedFilters.Excluded != nil { + assert.Equal(t, len(tt.expectedFilters.Excluded), len(payload.Filters.Excluded)) + for i, expected := range tt.expectedFilters.Excluded { + assert.Equal(t, expected.PackageType, payload.Filters.Excluded[i].PackageType) + assert.Equal(t, expected.PackageName, payload.Filters.Excluded[i].PackageName) + assert.Equal(t, expected.PackageVersion, payload.Filters.Excluded[i].PackageVersion) + assert.Equal(t, expected.Path, payload.Filters.Excluded[i].Path) + assert.Equal(t, expected.SHA256, payload.Filters.Excluded[i].SHA256) + } + } + } + } + }) + } +} diff --git a/apptrust/commands/version/testfiles/filters-spec.json b/apptrust/commands/version/testfiles/filters-spec.json new file mode 100644 index 0000000..f8b4a2d --- /dev/null +++ b/apptrust/commands/version/testfiles/filters-spec.json @@ -0,0 +1,23 @@ +{ + "packages": [ + { + "type": "npm", + "name": "pkg-with-filters", + "version": "1.0.0", + "repository_key": "repo-filters" + } + ], + "filters": { + "included": [ + { + "package_type": "docker", + "package_name": "frontend-*" + } + ], + "excluded": [ + { + "path": "libs/vulnerable-*.jar" + } + ] + } +} diff --git a/apptrust/model/create_app_version_request.go b/apptrust/model/create_app_version_request.go index 3ea3a7f..41986f1 100644 --- a/apptrust/model/create_app_version_request.go +++ b/apptrust/model/create_app_version_request.go @@ -5,6 +5,7 @@ type CreateAppVersionRequest struct { Version string `json:"version"` Sources *CreateVersionSources `json:"sources,omitempty"` Tag string `json:"tag,omitempty"` + Filters *CreateVersionFilters `json:"filters,omitempty"` } type CreateVersionPackage struct { @@ -22,6 +23,19 @@ type CreateVersionSources struct { Versions []CreateVersionReference `json:"versions,omitempty"` } +type CreateVersionSourceFilter struct { + PackageType string `json:"package_type,omitempty"` + PackageName string `json:"package_name,omitempty"` + PackageVersion string `json:"package_version,omitempty"` + Path string `json:"path,omitempty"` + SHA256 string `json:"sha256,omitempty"` +} + +type CreateVersionFilters struct { + Included []*CreateVersionSourceFilter `json:"included,omitempty"` + Excluded []*CreateVersionSourceFilter `json:"excluded,omitempty"` +} + type CreateVersionArtifact struct { Path string `json:"path"` SHA256 string `json:"sha256,omitempty"`