diff --git a/cliv2/cmd/cliv2/logheader.go b/cliv2/cmd/cliv2/logheaderfooter.go similarity index 55% rename from cliv2/cmd/cliv2/logheader.go rename to cliv2/cmd/cliv2/logheaderfooter.go index ba6f45ff83..d86ed62efb 100644 --- a/cliv2/cmd/cliv2/logheader.go +++ b/cliv2/cmd/cliv2/logheaderfooter.go @@ -6,20 +6,23 @@ import _ "github.com/snyk/go-application-framework/pkg/networking/fips_enable" import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "net/http" "regexp" + "strconv" "strings" - "github.com/snyk/go-application-framework/pkg/local_workflows/config_utils" - + "github.com/snyk/cli/cliv2/internal/cliv2" + "github.com/snyk/cli/cliv2/internal/utils" + "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/go-application-framework/pkg/auth" "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/local_workflows/config_utils" + localworkflows "github.com/snyk/go-application-framework/pkg/local_workflows" "github.com/snyk/go-application-framework/pkg/networking" "github.com/snyk/go-application-framework/pkg/networking/fips" - - "github.com/snyk/cli/cliv2/internal/cliv2" ) func logHeaderAuthorizationInfo( @@ -82,6 +85,14 @@ func getFipsStatus(config configuration.Configuration) string { return fipsEnabled } +func tablePrint(name string, value string) { + title := name + if len(name) > 0 { + title = title + ":" + } + globalLogger.Printf("%-22s %s", title, value) +} + func writeLogHeader(config configuration.Configuration, networkAccess networking.NetworkAccess) { authorization, _, userAgent := logHeaderAuthorizationInfo(config, networkAccess) @@ -101,10 +112,6 @@ func writeLogHeader(config configuration.Configuration, networkAccess networking previewFeaturesEnabled = "enabled" } - tablePrint := func(name string, value string) { - globalLogger.Printf("%-22s %s", name+":", value) - } - fipsEnabled := getFipsStatus(config) tablePrint("Version", cliv2.GetFullVersion()+" "+buildType) @@ -136,3 +143,113 @@ func writeLogHeader(config configuration.Configuration, networkAccess networking tablePrint(" Configuration", "all good") } } + +func writeLogFooter(exitCode int, errs []error) { + // output error details + if exitCode > 1 && len(errs) > 0 { + ecErrs := formatErrorCatalogErrors(errs) + + for i, err := range ecErrs { + tablePrint(fmt.Sprintf("Error (%d)", i), fmt.Sprintf("%s (%s)", err.ErrorCode, err.Title)) + tablePrint(" Type", err.Type) + tablePrint(" Classification", err.Classification) + + // description + if _, ok := err.Meta["description"]; ok && len(err.Description) == 0 { + tablePrint(" Description", err.Meta["description"].(string)) + } + tablePrint(" Description", err.Description) + + // details + _, hasDetails := err.Meta["details"] + if hasDetails && len(err.Meta["details"].([]string)) > 0 { + tablePrint(" Details", "") + for i, details := range utils.Dedupe(err.Meta["details"].([]string)) { + tablePrint(fmt.Sprintf(" %d", i), details) + } + } + + // links + if len(err.Links) > 0 { + tablePrint(" Links", "") + for i, link := range err.Links { + tablePrint(fmt.Sprintf(" %d", i), link) + } + } + + // requests + _, hasRequestDetails := err.Meta["requests"] + if hasRequestDetails && len(err.Meta["requests"].([]string)) > 0 { + tablePrint(" Requests", "") + for i, request := range err.Meta["requests"].([]string) { + tablePrint(fmt.Sprintf(" %d", i), request) + } + } + } + } + tablePrint("Exit Code", strconv.Itoa(exitCode)) +} + +func formatErrorCatalogErrors(errs []error) []snyk_errors.Error { + var formattedErrs []snyk_errors.Error + + for _, err := range errs { + var snykError snyk_errors.Error + if errors.As(err, &snykError) { + snykError = updateMeta(snykError) + + // Check if an error with the same ErrorCode already exists in formattedErrs + found := false + for i, fErr := range formattedErrs { + if snykError.ErrorCode == fErr.ErrorCode { + // Merge requests + formattedErrs[i].Meta["requests"] = append( + fErr.Meta["requests"].([]string), + snykError.Meta["requests"].([]string)..., + ) + // Merge details + formattedErrs[i].Meta["details"] = append( + fErr.Meta["details"].([]string), + snykError.Meta["details"].([]string)..., + ) + found = true + break + } + } + + // If no matching error was found, append the current error + if !found { + formattedErrs = append(formattedErrs, snykError) + } + } + } + + return formattedErrs +} + +func updateMeta(err snyk_errors.Error) snyk_errors.Error { + if err.Meta == nil { + err.Meta = make(map[string]interface{}) + } + + // add requests meta + if _, ok := err.Meta["requests"]; !ok { + err.Meta["requests"] = []string{} + } + if requestID, hasID := err.Meta["request-id"]; hasID { + if requestPath, hasPath := err.Meta["request-path"]; hasPath { + err.Meta["requests"] = append( + err.Meta["requests"].([]string), + fmt.Sprintf("%s - %s", requestID, requestPath), + ) + } + } + + // add details meta + if _, ok := err.Meta["details"]; !ok { + err.Meta["details"] = []string{} + } + err.Meta["details"] = append(err.Meta["details"].([]string), err.Detail) + + return err +} diff --git a/cliv2/cmd/cliv2/main.go b/cliv2/cmd/cliv2/main.go index 0a02fb75c3..2b11fd275d 100644 --- a/cliv2/cmd/cliv2/main.go +++ b/cliv2/cmd/cliv2/main.go @@ -79,8 +79,8 @@ const ( ) func main() { - errorCode := MainWithErrorCode() - globalLogger.Printf("Exiting with %d", errorCode) + errorCode, errs := MainWithErrorCode() + writeLogFooter(errorCode, errs) os.Exit(errorCode) } @@ -478,9 +478,12 @@ func displayError(err error, userInterface ui.UserInterface, config configuratio } } -func MainWithErrorCode() int { +func MainWithErrorCode() (int, []error) { initDebugBuild() + errorList := []error{} + errorListMutex := sync.Mutex{} + startTime := time.Now() var err error rInfo := runtimeinfo.New(runtimeinfo.WithName("snyk-cli"), runtimeinfo.WithVersion(cliv2.GetFullVersion())) @@ -528,7 +531,7 @@ func MainWithErrorCode() int { err = globalEngine.Init() if err != nil { globalLogger.Print("Failed to init Workflow Engine!", err) - return constants.SNYK_EXIT_CODE_ERROR + return constants.SNYK_EXIT_CODE_ERROR, errorList } // add output flags as persistent flags @@ -539,9 +542,6 @@ func MainWithErrorCode() int { // add workflows as commands createCommandsForWorkflows(rootCommand, globalEngine) - errorList := []error{} - errorListMutex := sync.Mutex{} - // init NetworkAccess ua := networking.UserAgent(networking.UaWithConfig(globalConfiguration), networking.UaWithRuntimeInfo(rInfo), networking.UaWithOS(internalOS)) networkAccess := globalEngine.GetNetworkAccess() @@ -592,12 +592,11 @@ func MainWithErrorCode() int { } if err != nil { + errorList = append(errorList, err) for _, tempError := range errorList { cliAnalytics.AddError(tempError) } - cliAnalytics.AddError(err) - err = legacyCLITerminated(err, errorList) } @@ -633,7 +632,7 @@ func MainWithErrorCode() int { globalLogger.Printf("Failed to cleanup %v", err) } - return exitCode + return exitCode, errorList } func legacyCLITerminated(err error, errorList []error) error { diff --git a/cliv2/cmd/cliv2/main_test.go b/cliv2/cmd/cliv2/main_test.go index ae87b068a3..fcf078cc2f 100644 --- a/cliv2/cmd/cliv2/main_test.go +++ b/cliv2/cmd/cliv2/main_test.go @@ -44,9 +44,32 @@ func Test_MainWithErrorCode(t *testing.T) { os.Args = []string{"snyk", "--version"} defer func() { os.Args = oldArgs }() - err := MainWithErrorCode() - - assert.Equal(t, 0, err) + errCode, _ := MainWithErrorCode() + + assert.Equal(t, 0, errCode) + + t.Run("outputs an error list", func(t *testing.T) { + t.Setenv("SNYK_TOKEN", "invalidToken") + defer cleanup() + oldArgs := append([]string{}, os.Args...) + os.Args = []string{"snyk", "whoami", "--experimental"} + defer func() { + os.Args = oldArgs + }() + + errCode, errs := MainWithErrorCode() + assert.Equal(t, 2, errCode) + + unauthorizedErrorCode := "SNYK-0005" + var actualErrorCodes []string + for _, err := range errs { + var snykError snyk_errors.Error + if errors.As(err, &snykError) { + actualErrorCodes = append(actualErrorCodes, snykError.ErrorCode) + } + } + assert.Contains(t, actualErrorCodes, unauthorizedErrorCode) + }) } func Test_initApplicationConfiguration_DisablesAnalytics(t *testing.T) { diff --git a/cliv2/internal/cliv2/cliv2.go b/cliv2/internal/cliv2/cliv2.go index 3a6bc13433..8b09357d46 100644 --- a/cliv2/internal/cliv2/cliv2.go +++ b/cliv2/internal/cliv2/cliv2.go @@ -13,13 +13,16 @@ import ( "os" "os/exec" "path" + "path/filepath" "regexp" "slices" "strings" "time" "github.com/gofrs/flock" + "github.com/google/uuid" "github.com/rs/zerolog" + "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/instrumentation" "github.com/snyk/go-application-framework/pkg/runtimeinfo" @@ -58,6 +61,8 @@ const ( V2_ABOUT Handler = iota ) +const configKeyErrFile = "INTERNAL_ERR_FILE_PATH" + func NewCLIv2(config configuration.Configuration, debugLogger *log.Logger, ri runtimeinfo.RuntimeInfo) (*CLI, error) { cacheDirectory := config.GetString(configuration.CACHE_PATH) @@ -351,6 +356,7 @@ func PrepareV1EnvironmentVariables( // Fill environment variables for the legacy CLI from the given configuration. func fillEnvironmentFromConfig(inputAsMap map[string]string, config configuration.Configuration, args []string) { inputAsMap[constants.SNYK_INTERNAL_ORGID_ENV] = config.GetString(configuration.ORGANIZATION) + inputAsMap[constants.SNYK_INTERNAL_ERR_FILE] = config.GetString(configKeyErrFile) if config.GetBool(configuration.PREVIEW_FEATURES_ENABLED) { inputAsMap[constants.SNYK_INTERNAL_PREVIEW_FEATURES_ENABLED] = "1" @@ -402,6 +408,9 @@ func (c *CLI) executeV1Default(proxyInfo *proxy.ProxyInfo, passThroughArgs []str defer cancel() } + filePath := filepath.Join(c.globalConfig.GetString(configuration.TEMP_DIR_PATH), fmt.Sprintf("err-file-%s", uuid.NewString())) + c.globalConfig.Set(configKeyErrFile, filePath) + snykCmd, err := c.PrepareV1Command(ctx, c.v1BinaryLocation, passThroughArgs, proxyInfo, c.GetIntegrationName(), GetFullVersion()) if c.DebugLogger.Writer() != io.Discard { @@ -447,9 +456,47 @@ func (c *CLI) executeV1Default(proxyInfo *proxy.ProxyInfo, passThroughArgs []str if errors.Is(ctx.Err(), context.DeadlineExceeded) { return ctx.Err() } + + if err == nil { + return nil + } + + if sentErrs, fileErr := c.getErrorFromFile(filePath); fileErr == nil { + err = errors.Join(err, sentErrs) + } + return err } +func (c *CLI) getErrorFromFile(errFilePath string) (data error, err error) { + bytes, fileErr := os.ReadFile(errFilePath) + if fileErr != nil { + c.DebugLogger.Println("Failed to read error file: ", fileErr) + return nil, fileErr + } + + jsonErrors, serErr := snyk_errors.FromJSONAPIErrorBytes(bytes) + if serErr != nil { + c.DebugLogger.Println("Failed to deserialize file: ", serErr) + return nil, fileErr + } + + if len(jsonErrors) != 0 { + errs := make([]error, len(jsonErrors)+1) + for _, jerr := range jsonErrors { + jerr.Meta["orign"] = "Typescript-CLI" + errs = append(errs, jerr) + } + + err = errors.Join(errs...) + c.DebugLogger.Println("Error file contained ", len(jsonErrors), " errors: ", err) + return err, nil + } + + c.DebugLogger.Println("The file didn't contain any errors") + return nil, errors.New("no errorrs were sent thought the error file") +} + func (c *CLI) Execute(proxyInfo *proxy.ProxyInfo, passThroughArgs []string) error { var err error handler := determineHandler(passThroughArgs) diff --git a/cliv2/internal/cliv2/cliv2_test.go b/cliv2/internal/cliv2/cliv2_test.go index 27d563e0e8..6420020510 100644 --- a/cliv2/internal/cliv2/cliv2_test.go +++ b/cliv2/internal/cliv2/cliv2_test.go @@ -74,6 +74,7 @@ func Test_PrepareV1EnvironmentVariables_Fill_and_Filter(t *testing.T) { "HTTPS_PROXY=proxy", "NODE_EXTRA_CA_CERTS=cacertlocation", "SNYK_SYSTEM_NO_PROXY=noProxy", + "SNYK_ERR_FILE=", "SNYK_SYSTEM_HTTP_PROXY=httpProxy", "SNYK_SYSTEM_HTTPS_PROXY=httpsProxy", "SNYK_INTERNAL_ORGID=" + orgid, @@ -112,6 +113,7 @@ func Test_PrepareV1EnvironmentVariables_DontOverrideExistingIntegration(t *testi "SNYK_SYSTEM_NO_PROXY=", "SNYK_SYSTEM_HTTP_PROXY=", "SNYK_SYSTEM_HTTPS_PROXY=", + "SNYK_ERR_FILE=", "SNYK_INTERNAL_ORGID=" + orgid, "SNYK_CFG_ORG=" + orgid, "SNYK_API=" + testapi, @@ -146,6 +148,7 @@ func Test_PrepareV1EnvironmentVariables_OverrideProxyAndCerts(t *testing.T) { "NODE_EXTRA_CA_CERTS=cacertlocation", "SNYK_SYSTEM_NO_PROXY=312123", "SNYK_SYSTEM_HTTP_PROXY=exists", + "SNYK_ERR_FILE=", "SNYK_SYSTEM_HTTPS_PROXY=already", "SNYK_INTERNAL_ORGID=" + orgid, "SNYK_CFG_ORG=" + orgid, diff --git a/cliv2/internal/constants/constants.go b/cliv2/internal/constants/constants.go index b84b8cd4ee..cf65f6d547 100644 --- a/cliv2/internal/constants/constants.go +++ b/cliv2/internal/constants/constants.go @@ -23,6 +23,7 @@ const SNYK_OAUTH_ACCESS_TOKEN_ENV = "SNYK_OAUTH_TOKEN" const SNYK_API_TOKEN_ENV = "SNYK_TOKEN" const SNYK_ANALYTICS_DISABLED_ENV = "SNYK_DISABLE_ANALYTICS" const SNYK_INTERNAL_ORGID_ENV = "SNYK_INTERNAL_ORGID" +const SNYK_INTERNAL_ERR_FILE = "SNYK_ERR_FILE" const SNYK_INTERNAL_PREVIEW_FEATURES_ENABLED = "SNYK_INTERNAL_PREVIEW_FEATURES" const SNYK_ENDPOINT_ENV = "SNYK_API" const SNYK_ORG_ENV = "SNYK_CFG_ORG" diff --git a/cliv2/internal/utils/helpers.go b/cliv2/internal/utils/helpers.go new file mode 100644 index 0000000000..8497b4c3a3 --- /dev/null +++ b/cliv2/internal/utils/helpers.go @@ -0,0 +1,38 @@ +package utils + +// Dedupe removes duplicate entries from a given slice. +// Returns a new, deduplicated slice. +// +// Example: +// +// mySlice := []string{"apple", "banana", "apple", "cherry", "banana", "date"} +// dedupedSlice := dedupe(mySlice) +// fmt.Println(dedupedSlice) // Output: [apple banana cherry date] +func Dedupe(s []string) []string { + seen := make(map[string]bool) + var result []string + for _, str := range s { + if _, ok := seen[str]; !ok { + seen[str] = true + result = append(result, str) + } + } + return result +} + +// Contains checks if a given string is in a given list of strings. +// Returns true if the element was found, false otherwise. +// +// Example: +// +// list := []string{"a", "b", "c"} +// element := "b" +// contains := Contains(list, element) // contains is true +func Contains(list []string, element string) bool { + for _, a := range list { + if a == element { + return true + } + } + return false +} diff --git a/jest.config.js b/jest.config.js index 3e3fdb574d..6eea81c7ea 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,4 +5,5 @@ module.exports = createJestConfig({ displayName: 'coreCli', projects: ['', '/packages/*'], globalSetup: './test/setup.js', + setupFilesAfterEnv: ['./test/setup-jest.ts'], }); diff --git a/package-lock.json b/package-lock.json index 8d6d97a6ed..214e597e1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@snyk/code-client": "^4.23.5", "@snyk/dep-graph": "^2.7.4", "@snyk/docker-registry-v2-client": "^2.11.0", + "@snyk/error-catalog-nodejs-public": "^5.37.0", "@snyk/fix": "file:packages/snyk-fix", "@snyk/gemfile": "1.2.0", "@snyk/snyk-cocoapods-plugin": "2.5.3", @@ -3078,29 +3079,32 @@ } }, "node_modules/@snyk/error-catalog-nodejs-public": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-4.0.4.tgz", - "integrity": "sha512-M+t/MNfR/qr/Rdxc3Kl2p26mIx0YdcM22CAZfNsCuldl1DIZQma8jc7zmm14AwhwmdoU6TE7mzzO33KINgB8LA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-5.37.0.tgz", + "integrity": "sha512-63SNBN5XC0fD2zmFYJtMtW8jg3kf774CCCOqMikyZW1rknkxnLiTFbGgA/Xwwjse3TobGxlltfBHt0yPpcRSPw==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2", - "uuid": "^9.0.0" + "tslib": "^2.8.1", + "uuid": "^11.0.5" } }, "node_modules/@snyk/error-catalog-nodejs-public/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@snyk/error-catalog-nodejs-public/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/@snyk/fix": { @@ -9191,6 +9195,35 @@ "node": ">=14" } }, + "node_modules/dotnet-deps-parser/node_modules/@snyk/error-catalog-nodejs-public": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-4.0.4.tgz", + "integrity": "sha512-M+t/MNfR/qr/Rdxc3Kl2p26mIx0YdcM22CAZfNsCuldl1DIZQma8jc7zmm14AwhwmdoU6TE7mzzO33KINgB8LA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2", + "uuid": "^9.0.0" + } + }, + "node_modules/dotnet-deps-parser/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/dotnet-deps-parser/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -20551,32 +20584,6 @@ "node": ">=10" } }, - "node_modules/snyk-nodejs-lockfile-parser/node_modules/@snyk/error-catalog-nodejs-public": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-5.18.1.tgz", - "integrity": "sha512-ZYkh3GonDljGSPsk2Rp+KtSgbuWCXTQwr4OtVP9BamhSulHa+5nST4ISyvhazfOnbumauVa0m7D54PLEQQ5M7w==", - "dependencies": { - "tslib": "^2.6.2", - "uuid": "^9.0.0" - } - }, - "node_modules/snyk-nodejs-lockfile-parser/node_modules/@snyk/error-catalog-nodejs-public/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "node_modules/snyk-nodejs-lockfile-parser/node_modules/@snyk/error-catalog-nodejs-public/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/snyk-nodejs-lockfile-parser/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -20780,10 +20787,34 @@ "node": ">=8" } }, + "node_modules/snyk-poetry-lockfile-parser/node_modules/@snyk/error-catalog-nodejs-public": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-4.0.4.tgz", + "integrity": "sha512-M+t/MNfR/qr/Rdxc3Kl2p26mIx0YdcM22CAZfNsCuldl1DIZQma8jc7zmm14AwhwmdoU6TE7mzzO33KINgB8LA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2", + "uuid": "^9.0.0" + } + }, "node_modules/snyk-poetry-lockfile-parser/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/snyk-poetry-lockfile-parser/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } }, "node_modules/snyk-policy": { "version": "4.1.4", @@ -26304,23 +26335,23 @@ } }, "@snyk/error-catalog-nodejs-public": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-4.0.4.tgz", - "integrity": "sha512-M+t/MNfR/qr/Rdxc3Kl2p26mIx0YdcM22CAZfNsCuldl1DIZQma8jc7zmm14AwhwmdoU6TE7mzzO33KINgB8LA==", + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-5.37.0.tgz", + "integrity": "sha512-63SNBN5XC0fD2zmFYJtMtW8jg3kf774CCCOqMikyZW1rknkxnLiTFbGgA/Xwwjse3TobGxlltfBHt0yPpcRSPw==", "requires": { - "tslib": "^2.6.2", - "uuid": "^9.0.0" + "tslib": "^2.8.1", + "uuid": "^11.0.5" }, "dependencies": { "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==" } } }, @@ -31004,6 +31035,27 @@ "lodash": "^4.17.21", "source-map-support": "^0.5.21", "xml2js": "0.6.2" + }, + "dependencies": { + "@snyk/error-catalog-nodejs-public": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-4.0.4.tgz", + "integrity": "sha512-M+t/MNfR/qr/Rdxc3Kl2p26mIx0YdcM22CAZfNsCuldl1DIZQma8jc7zmm14AwhwmdoU6TE7mzzO33KINgB8LA==", + "requires": { + "tslib": "^2.6.2", + "uuid": "^9.0.0" + } + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + } } }, "duplexify": { @@ -39521,27 +39573,6 @@ "uuid": "^8.3.0" }, "dependencies": { - "@snyk/error-catalog-nodejs-public": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-5.18.1.tgz", - "integrity": "sha512-ZYkh3GonDljGSPsk2Rp+KtSgbuWCXTQwr4OtVP9BamhSulHa+5nST4ISyvhazfOnbumauVa0m7D54PLEQQ5M7w==", - "requires": { - "tslib": "^2.6.2", - "uuid": "^9.0.0" - }, - "dependencies": { - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" - } - } - }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -39707,10 +39738,24 @@ "tslib": "^2.0.0" }, "dependencies": { + "@snyk/error-catalog-nodejs-public": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-4.0.4.tgz", + "integrity": "sha512-M+t/MNfR/qr/Rdxc3Kl2p26mIx0YdcM22CAZfNsCuldl1DIZQma8jc7zmm14AwhwmdoU6TE7mzzO33KINgB8LA==", + "requires": { + "tslib": "^2.6.2", + "uuid": "^9.0.0" + } + }, "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" } } }, diff --git a/package.json b/package.json index 2fab2a8565..d6302fe41c 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@snyk/code-client": "^4.23.5", "@snyk/dep-graph": "^2.7.4", "@snyk/docker-registry-v2-client": "^2.11.0", + "@snyk/error-catalog-nodejs-public": "^5.37.0", "@snyk/fix": "file:packages/snyk-fix", "@snyk/gemfile": "1.2.0", "@snyk/snyk-cocoapods-plugin": "2.5.3", diff --git a/src/cli/ipc.ts b/src/cli/ipc.ts new file mode 100644 index 0000000000..fecaeede8f --- /dev/null +++ b/src/cli/ipc.ts @@ -0,0 +1,45 @@ +import { writeFile } from 'fs/promises'; +import { CLI, ProblemError } from '@snyk/error-catalog-nodejs-public'; +import { debug as Debug } from 'debug'; +import * as legacyErrors from '../lib/errors/legacy-errors'; +import stripAnsi = require('strip-ansi'); + +const ERROR_FILE_PATH = process.env.SNYK_ERR_FILE; +const debug = Debug('snyk'); + +/** + * Sends the specified error back at the Golang CLI, by writting it to the temporary error file. Errors that are not + * inlcuded in the Error Catalog will be wraped in a generic model. + * @param err {Error} The error to be sent to the Golang CLI + * @returns {Promise} The result of the operation as a boolean value + */ +export async function sendError(err: Error): Promise { + if (!ERROR_FILE_PATH) { + debug('Error file path not set.'); + return false; + } + + // @ts-expect-error Using this instead of 'instanceof' since the error might be caught from external CLI plugins. + // See: https://github.com/snyk/error-catalog/blob/main/packages/error-catalog-nodejs/src/problem-error.ts#L17-L19 + if (!err.isErrorCatalogError) { + const detail: string = stripAnsi(legacyErrors.message(err)); + if (!detail || detail.trim().length === 0) return false; + + err = new CLI.GeneralCLIFailureError(detail); + // @ts-expect-error Overriding with specific err code from CustomErrors, or 0 for + err.metadata.status = 0; + } + + const data = (err as ProblemError) + .toJsonApi('error-catalog-ipc-instance?') + .body(); + + try { + await writeFile(ERROR_FILE_PATH, JSON.stringify(data)); + } catch (e) { + debug('Failed to write data to error file: ', e); + return false; + } + + return true; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index b03d6f1d2c..89485f2297 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -48,6 +48,8 @@ import { SarifFileOutputEmptyError } from '../lib/errors/empty-sarif-output-erro import { InvalidDetectionDepthValue } from '../lib/errors/invalid-detection-depth-value'; import { obfuscateArgs } from '../lib/utils'; import { EXIT_CODES } from './exit-codes'; +import { sendError } from './ipc'; + const isEmpty = require('lodash/isEmpty'); const debug = Debug('snyk'); @@ -135,36 +137,47 @@ async function handleError(args, error) { } } - if (args.options.debug && !args.options.json) { - const output = vulnsFound ? error.message : error.stack; - console.log(output); - } else if ( - args.options.json && - !(error instanceof UnsupportedOptionCombinationError) - ) { - const output = vulnsFound - ? error.message - : stripAnsi(error.json || error.stack); - if (error.jsonPayload) { - new JsonStreamStringify(error.jsonPayload, undefined, 2).pipe( - process.stdout, - ); - } else { + /** + * Exceptions from sending errors + * - json/sarif flags - this would just stringify the content as the error message; could look into outputing the Error Catalog JSON + * - vulnsFound - issues are treated as errors (exit code 1), this should be some nice pretty formated output for users. + */ + const errorSent = + args.options.json || args.options.sarif || vulnsFound + ? false + : sendError(error); + if (!errorSent) { + if (args.options.debug && !args.options.json) { + const output = vulnsFound ? error.message : error.stack; console.log(output); - } - } else { - if (!args.options.quiet) { - const result = errors.message(error); - if (args.options.copy) { - copy(result); - console.log('Result copied to clipboard'); + } else if ( + args.options.json && + !(error instanceof UnsupportedOptionCombinationError) + ) { + const output = vulnsFound + ? error.message + : stripAnsi(error.json || error.stack); + if (error.jsonPayload) { + new JsonStreamStringify(error.jsonPayload, undefined, 2).pipe( + process.stdout, + ); } else { - if (`${error.code}`.indexOf('AUTH_') === 0) { - // remove the last few lines - const erase = ansiEscapes.eraseLines(4); - process.stdout.write(erase); + console.log(output); + } + } else { + if (!args.options.quiet) { + const result = errors.message(error); + if (args.options.copy) { + copy(result); + console.log('Result copied to clipboard'); + } else { + if (`${error.code}`.indexOf('AUTH_') === 0) { + // remove the last few lines + const erase = ansiEscapes.eraseLines(4); + process.stdout.write(erase); + } + console.log(result); } - console.log(result); } } } diff --git a/test/jest/acceptance/cli-args.spec.ts b/test/jest/acceptance/cli-args.spec.ts index c5344f52de..fd47b48626 100644 --- a/test/jest/acceptance/cli-args.spec.ts +++ b/test/jest/acceptance/cli-args.spec.ts @@ -60,7 +60,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`test --file package-lock.json`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Empty --file argument. Did you mean --file=path/to/file ?', ); expect(code).toEqual(2); @@ -78,7 +78,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`test --packageManager=hello`, { env, }); - expect(stdout).toMatch('Unsupported package manager'); + expect(stdout).toContainText('Unsupported package manager'); expect(code).toEqual(2); }); @@ -90,7 +90,7 @@ describe('cli args', () => { }, ); - expect(stdout).toMatch('Unsupported flag'); + expect(stdout).toContainText('Unsupported flag'); expect(code).toEqual(2); }); @@ -101,7 +101,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'The following option combination is not currently supported: multiple paths + project-name', ); expect(code).toEqual(2); @@ -115,7 +115,7 @@ describe('cli args', () => { }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'The following option combination is not currently supported: file=*.sln + project-name', ); expect(code).toEqual(2); @@ -128,7 +128,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'The following option combination is not currently supported: file + scan-all-unmanaged', ); expect(code).toEqual(2); @@ -141,7 +141,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'The following option combination is not currently supported: maven-aggregate-project + project-name', ); expect(code).toEqual(2); @@ -161,7 +161,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( `The following option combination is not currently supported: ${arg} + yarn-workspaces`, ); expect(code).toEqual(2); @@ -174,7 +174,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( `The following option combination is not currently supported: ${arg} + yarn-workspaces`, ); expect(code).toEqual(2); @@ -191,7 +191,7 @@ describe('cli args', () => { ].forEach((arg) => { test(`test using --${arg} and --all-projects displays error message`, async () => { const { code, stdout } = await runSnykCLI(`test --${arg} --all-projects`); - expect(stdout).toMatch( + expect(stdout).toContainText( `The following option combination is not currently supported: ${arg} + all-projects`, ); expect(code).toEqual(2); @@ -204,7 +204,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( `The following option combination is not currently supported: ${arg} + all-projects`, ); expect(code).toEqual(2); @@ -215,7 +215,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`test --exclude=test`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( 'The --exclude option can only be use in combination with --all-projects or --yarn-workspaces.', ); expect(code).toEqual(2); @@ -225,7 +225,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`test --all-projects --exclude`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Empty --exclude argument. Did you mean --exclude=subdirectory ?', ); expect(code).toEqual(2); @@ -240,7 +240,7 @@ describe('cli args', () => { }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'The --exclude argument must be a comma separated list of directory or file names and cannot contain a path.', ); expect(code).toEqual(2); @@ -264,7 +264,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( `The following option combination is not currently supported: ${command} + json-file-output`, ); expect(code).toEqual(2); @@ -278,7 +278,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( "--project-business-criticality must contain an '=' with a comma-separated list of values. To clear all existing values, pass no values i.e. --project-business-criticality=", ); expect(code).toEqual(2); @@ -288,7 +288,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`monitor --project-tags`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( "--project-tags must contain an '=' with a comma-separated list of pairs (also separated with an '='). To clear all existing values, pass no values i.e. --project-tags=", ); expect(code).toEqual(2); @@ -301,7 +301,7 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( "--project-tags must contain an '=' with a comma-separated list of pairs (also separated with an '='). To clear all existing values, pass no values i.e. --project-tags=", ); expect(code).toEqual(2); @@ -319,7 +319,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`test ${option}`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Empty --json-file-output argument. Did you mean --file=path/to/output-file.json ?', ); expect(code).toEqual(2); @@ -330,11 +330,10 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`iac test --sarif --json`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( new UnsupportedOptionCombinationError(['test', 'sarif', 'json']) .userMessage, ); - expect(stdout.trim().split('\n')).toHaveLength(1); expect(code).toEqual(2); }); @@ -345,11 +344,10 @@ describe('cli args', () => { env, }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( new UnsupportedOptionCombinationError(['test', 'sarif', 'json']) .userMessage, ); - expect(stdout.trim().split('\n')).toHaveLength(1); expect(code).toEqual(2); }); @@ -363,7 +361,7 @@ describe('cli args', () => { const { code, stdout } = await runSnykCLI(`test ${option}`, { env, }); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Empty --sarif-file-output argument. Did you mean --file=path/to/output-file.json ?', ); expect(code).toEqual(2); @@ -387,7 +385,7 @@ describe('cli args', () => { const sarifOutput = await project.readJSON(sarifPath); const jsonOutput = await project.readJSON(jsonPath); - expect(stdout).toMatch('Organization:'); + expect(stdout).toContainText('Organization:'); expect(jsonOutput.ok).toEqual(true); expect(sarifOutput.version).toMatch('2.1.0'); expect(code).toEqual(0); @@ -407,7 +405,7 @@ describe('cli args', () => { ); const sarifOutput = await project.readJSON(sarifPath); - expect(stdout).toMatch('rules'); + expect(stdout).toContainText('rules'); expect(sarifOutput.version).toMatch('2.1.0'); expect(code).toEqual(0); }); diff --git a/test/jest/acceptance/iac/check-paths-regression.spec.ts b/test/jest/acceptance/iac/check-paths-regression.spec.ts index ebfad6b391..a6d53bd8e4 100644 --- a/test/jest/acceptance/iac/check-paths-regression.spec.ts +++ b/test/jest/acceptance/iac/check-paths-regression.spec.ts @@ -1,5 +1,5 @@ -import { EOL } from 'os'; import { startMockServer } from './helpers'; +import { NoFilesToScanError } from '../../../../src/cli/commands/test/iac/local-execution/file-loader'; jest.setTimeout(50000); @@ -17,18 +17,12 @@ describe('checkPath() regression test snyk/cli#3406', () => { afterAll(async () => teardown()); + const filePath = './iac/check-paths-regression/package.json'; + it('supports scanning a project matching an OSS manifest name', async () => { - const { stdout, exitCode } = await run( - `snyk iac test ./iac/check-paths-regression/package.json`, - ); - expect(stdout).not.toContain( - '--file=iac/check-paths-regression/package.json', - ); - expect(stdout).toContain( - 'Could not find any valid IaC files' + - EOL + - ' Path: ./iac/check-paths-regression/package.json', - ); + const { stdout, exitCode } = await run(`snyk iac test ${filePath}`); + expect(stdout).not.toContain(`--file=${filePath}`); + expect(stdout).toContainText(new NoFilesToScanError().message); expect(exitCode).toBe(3); }); }); diff --git a/test/jest/acceptance/iac/cli-share-results.spec.ts b/test/jest/acceptance/iac/cli-share-results.spec.ts index e9c0deb686..2bd9ca0256 100644 --- a/test/jest/acceptance/iac/cli-share-results.spec.ts +++ b/test/jest/acceptance/iac/cli-share-results.spec.ts @@ -32,7 +32,7 @@ describe('CLI Share Results', () => { `snyk iac test ./iac/arm/rule_test.json --report`, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Flag "--report" is only supported if feature flag "iacCliShareResults" is enabled. To enable it, please contact Snyk support.', ); expect(exitCode).toBe(2); diff --git a/test/jest/acceptance/iac/custom-rules.spec.ts b/test/jest/acceptance/iac/custom-rules.spec.ts index bb85a2092e..eddec8ccec 100644 --- a/test/jest/acceptance/iac/custom-rules.spec.ts +++ b/test/jest/acceptance/iac/custom-rules.spec.ts @@ -60,7 +60,7 @@ describe('iac test --rules', () => { const { stdout, exitCode } = await run( `snyk iac test --org=no-custom-rules-entitlements --rules=./iac/custom-rules/custom.tar.gz ./iac/terraform/sg_open_ssh.tf`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `Flag "--rules" is currently not supported for this org. To enable it, please contact snyk support.`, ); expect(exitCode).toBe(2); diff --git a/test/jest/acceptance/iac/describe.spec.ts b/test/jest/acceptance/iac/describe.spec.ts index ae1bc8fa5f..cd67805ff2 100644 --- a/test/jest/acceptance/iac/describe.spec.ts +++ b/test/jest/acceptance/iac/describe.spec.ts @@ -54,7 +54,7 @@ describe('iac describe', () => { }, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Command "drift" is currently not supported for this org. To enable it, please contact snyk support.', ); expect(stderr).toMatch(''); diff --git a/test/jest/acceptance/iac/iac-entitlement.spec.ts b/test/jest/acceptance/iac/iac-entitlement.spec.ts index 3c1ef5e70e..ac30fa43ea 100644 --- a/test/jest/acceptance/iac/iac-entitlement.spec.ts +++ b/test/jest/acceptance/iac/iac-entitlement.spec.ts @@ -18,7 +18,7 @@ describe('iac test with infrastructureAsCode entitlement', () => { const { stdout, exitCode } = await run( `snyk iac test --org=no-iac-entitlements ./iac/terraform/sg_open_ssh.tf`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'This feature is currently not enabled for your org. To enable it, please contact snyk support.', ); expect(exitCode).toBe(2); diff --git a/test/jest/acceptance/iac/output-formats/text.spec.ts b/test/jest/acceptance/iac/output-formats/text.spec.ts index e3593ca866..81883ee92b 100644 --- a/test/jest/acceptance/iac/output-formats/text.spec.ts +++ b/test/jest/acceptance/iac/output-formats/text.spec.ts @@ -52,7 +52,7 @@ describe('iac test text output', () => { it('should show the issues list section with correct values', async () => { const { stdout } = await run('snyk iac test ./iac/arm/rule_test.json'); - expect(stdout).toContain( + expect(stdout).toContainText( ' [Medium] Azure Firewall Network Rule Collection allows public access' + EOL + ' Info: That inbound traffic is allowed to a resource from any source instead' + @@ -182,7 +182,7 @@ describe('iac test text output', () => { ]; const { stdout } = await run(`snyk iac test ${invalidPaths.join(' ')}`); - expect(stdout).toContain( + expect(stdout).toContainText( ' Failed to parse YAML file' + EOL + ` Path: ${pathLib.join( diff --git a/test/jest/acceptance/iac/test-arm.spec.ts b/test/jest/acceptance/iac/test-arm.spec.ts index 4b34d3ba3b..1787915691 100644 --- a/test/jest/acceptance/iac/test-arm.spec.ts +++ b/test/jest/acceptance/iac/test-arm.spec.ts @@ -1,5 +1,7 @@ import { EOL } from 'os'; import { startMockServer, isValidJSONString } from './helpers'; +import { InvalidJsonFileError } from '../../../../src/cli/commands/test/iac/local-execution/yaml-parser'; + jest.setTimeout(50000); describe('ARM single file scan', () => { @@ -41,15 +43,10 @@ describe('ARM single file scan', () => { }); it('outputs an error for files with no valid JSON', async () => { - const { stdout, exitCode } = await run( - `snyk iac test ./iac/arm/invalid_rule_test.json`, - ); + const path = './iac/arm/invalid_rule_test.json'; + const { stdout, exitCode } = await run(`snyk iac test ${path}`); - expect(stdout).toContain( - 'Failed to parse JSON file' + - EOL + - ' Path: ./iac/arm/invalid_rule_test.json', - ); + expect(stdout).toContainText(new InvalidJsonFileError(path).message); expect(exitCode).toBe(2); }); diff --git a/test/jest/acceptance/iac/test-cloudformation.spec.ts b/test/jest/acceptance/iac/test-cloudformation.spec.ts index cb085519be..3de654eabc 100644 --- a/test/jest/acceptance/iac/test-cloudformation.spec.ts +++ b/test/jest/acceptance/iac/test-cloudformation.spec.ts @@ -1,5 +1,6 @@ import { EOL } from 'os'; import { startMockServer, isValidJSONString } from './helpers'; +import { InvalidYamlFileError } from '../../../../src/cli/commands/test/iac/local-execution/yaml-parser'; jest.setTimeout(50000); @@ -54,15 +55,10 @@ describe('CloudFormation single file scan', () => { }); it('outputs an error for files with no valid YAML', async () => { - const { stdout, exitCode } = await run( - `snyk iac test ./iac/cloudformation/invalid-cfn.yml`, - ); + const path = ' ./iac/cloudformation/invalid-cfn.yml'; + const { stdout, exitCode } = await run(`snyk iac test ${path}`); - expect(stdout).toContain( - 'Failed to parse YAML file' + - EOL + - ' Path: ./iac/cloudformation/invalid-cfn.yml', - ); + expect(stdout).toContainText(new InvalidYamlFileError(path).message); expect(exitCode).toBe(2); }); diff --git a/test/jest/acceptance/iac/test-directory.spec.ts b/test/jest/acceptance/iac/test-directory.spec.ts index 8fbe861a77..4cb2addb50 100644 --- a/test/jest/acceptance/iac/test-directory.spec.ts +++ b/test/jest/acceptance/iac/test-directory.spec.ts @@ -1,4 +1,5 @@ import { isValidJSONString, startMockServer } from './helpers'; +import { InvalidYamlFileError } from '../../../../src/cli/commands/test/iac/local-execution/yaml-parser'; import { EOL } from 'os'; import * as pathLib from 'path'; @@ -20,15 +21,15 @@ describe('Directory scan', () => { it('scans all files in a directory with Kubernetes files', async () => { const { stdout, exitCode } = await run(`snyk iac test ./iac/kubernetes/`); - expect(stdout).toContain('File: pod-privileged.yaml'); //directory scan shows relative path to cwd in output - expect(stdout).toContain('File: pod-privileged-multi.yaml'); - expect(stdout).toContain('File: pod-valid.json'); - expect(stdout).toContain( + expect(stdout).toContainText('File: pod-privileged.yaml'); //directory scan shows relative path to cwd in output + expect(stdout).toContainText('File: pod-privileged-multi.yaml'); + expect(stdout).toContainText('File: pod-valid.json'); + expect(stdout).toContainText( 'Failed to parse YAML file' + EOL + ` Path: ${pathLib.join('iac', 'kubernetes', 'helm-config.yaml')}`, ); - expect(stdout).toContain('Files with issues: 3'); + expect(stdout).toContainText('Files with issues: 3'); expect(exitCode).toBe(1); }); @@ -36,28 +37,28 @@ describe('Directory scan', () => { const { stdout, exitCode } = await run(`snyk iac test ./iac/`); //directory scan shows relative path to cwd in output // here we assert just on the filename to avoid the different slashes (/) for Unix/Windows on the CI runner - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('kubernetes', 'pod-privileged.yaml')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('kubernetes', 'pod-privileged-multi.yaml')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('arm', 'rule_test.json')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('cloudformation', 'aurora-valid.yml')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('cloudformation', 'fargate-valid.json')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('depth_detection', 'root.tf')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( `File: ${pathLib.join('terraform-plan', 'tf-plan-update.json')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'Failed to parse Terraform file' + EOL + ` Path: ${pathLib.join( @@ -72,7 +73,7 @@ describe('Directory scan', () => { 'sg_open_ssh_invalid_hcl2.tf', )}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'Failed to parse YAML file' + EOL + ` Path: ${pathLib.join('iac', 'cloudformation', 'invalid-cfn.yml')}` + @@ -83,7 +84,7 @@ describe('Directory scan', () => { EOL + ` ${pathLib.join('iac', 'only-invalid', 'invalid-file2.yaml')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'Failed to parse JSON file' + EOL + ` Path: ${pathLib.join('iac', 'arm', 'invalid_rule_test.json')}`, @@ -98,7 +99,7 @@ describe('Directory scan', () => { `snyk iac test ./iac/terraform --severity-threshold=high`, ); expect(exitCode).toBe(0); - expect(stdout).toContain( + expect(stdout).toContainText( 'Failed to parse Terraform file' + EOL + ` Path: ${pathLib.join( @@ -121,8 +122,8 @@ describe('Directory scan', () => { ); expect(exitCode).toBe(1); expect(isValidJSONString(stdout)).toBe(true); - expect(stdout).toContain('"id": "SNYK-CC-TF-1",'); - expect(stdout).toContain('"ruleId": "SNYK-CC-TF-1",'); + expect(stdout).toContainText('"id": "SNYK-CC-TF-1",'); + expect(stdout).toContainText('"ruleId": "SNYK-CC-TF-1",'); }); it('outputs the expected text when running with --json flag', async () => { @@ -131,8 +132,8 @@ describe('Directory scan', () => { ); expect(exitCode).toBe(1); expect(isValidJSONString(stdout)).toBe(true); - expect(stdout).toContain('"id": "SNYK-CC-TF-1",'); - expect(stdout).toContain('"packageManager": "terraformconfig",'); + expect(stdout).toContainText('"id": "SNYK-CC-TF-1",'); + expect(stdout).toContainText('"packageManager": "terraformconfig",'); }); it('limits the depth of the directories', async () => { @@ -143,9 +144,9 @@ describe('Directory scan', () => { // The CLI shows the output relative to the path: one/one.tf. // here we assert just on the filename to avoid the different slashes (/) for Unix/Windows on the CI runner - expect(stdout).toContain('Issues'); - expect(stdout).toContain('root.tf'); - expect(stdout).toContain( + expect(stdout).toContainText('Issues'); + expect(stdout).toContainText('root.tf'); + expect(stdout).toContainText( '✔ Files without issues: 1' + EOL + '✗ Files with issues: 1', ); expect(stdout).not.toContain('two.tf'); @@ -157,11 +158,11 @@ describe('Directory scan', () => { `snyk iac test ./iac/terraform ./iac/cloudformation ./iac/arm`, ); //directory scan shows relative path to cwd in output - expect(stdout).toContain('File: sg_open_ssh.tf'); - expect(stdout).toContain('File: aurora-valid.yml'); - expect(stdout).toContain('File: rule_test.json'); - expect(stdout).toContain('sg_open_ssh_invalid_hcl2.tf'); - expect(stdout).toContain( + expect(stdout).toContainText('File: sg_open_ssh.tf'); + expect(stdout).toContainText('File: aurora-valid.yml'); + expect(stdout).toContainText('File: rule_test.json'); + expect(stdout).toContainText('sg_open_ssh_invalid_hcl2.tf'); + expect(stdout).toContainText( 'Failed to parse Terraform file' + EOL + ` Path: ${pathLib.join( @@ -176,12 +177,12 @@ describe('Directory scan', () => { 'sg_open_ssh_invalid_hcl2.tf', )}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'Failed to parse YAML file' + EOL + ` Path: ${pathLib.join('iac', 'cloudformation', 'invalid-cfn.yml')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'Failed to parse JSON file' + EOL + ` Path: ${pathLib.join('iac', 'arm', 'invalid_rule_test.json')}`, @@ -195,13 +196,13 @@ describe('Directory scan', () => { ); expect(isValidJSONString(stdout)).toBe(true); - expect(stdout).toContain('"id": "SNYK-CC-TF-1",'); - expect(stdout).toContain('"id": "SNYK-CC-TF-124",'); - expect(stdout).toContain('"packageManager": "terraformconfig",'); - expect(stdout).toContain('"projectType": "terraformconfig",'); - expect(stdout).toContain('"id": "SNYK-CC-AWS-422",'); - expect(stdout).toContain('"packageManager": "cloudformationconfig",'); - expect(stdout).toContain('"projectType": "cloudformationconfig",'); + expect(stdout).toContainText('"id": "SNYK-CC-TF-1",'); + expect(stdout).toContainText('"id": "SNYK-CC-TF-124",'); + expect(stdout).toContainText('"packageManager": "terraformconfig",'); + expect(stdout).toContainText('"projectType": "terraformconfig",'); + expect(stdout).toContainText('"id": "SNYK-CC-AWS-422",'); + expect(stdout).toContainText('"packageManager": "cloudformationconfig",'); + expect(stdout).toContainText('"projectType": "cloudformationconfig",'); expect(exitCode).toBe(1); }); @@ -211,9 +212,9 @@ describe('Directory scan', () => { ); expect(isValidJSONString(stdout)).toBe(true); - expect(stdout).toContain('"ruleId": "SNYK-CC-TF-1",'); - expect(stdout).toContain('"ruleId": "SNYK-CC-TF-124",'); - expect(stdout).toContain('"ruleId": "SNYK-CC-AWS-422",'); + expect(stdout).toContainText('"ruleId": "SNYK-CC-TF-1",'); + expect(stdout).toContainText('"ruleId": "SNYK-CC-TF-124",'); + expect(stdout).toContainText('"ruleId": "SNYK-CC-AWS-422",'); expect(exitCode).toBe(1); }); @@ -223,13 +224,13 @@ describe('Directory scan', () => { `snyk iac test ./iac/arm non-existing-dir`, ); //directory scan shows relative path to cwd in output - expect(stdout).toContain('File: rule_test.json'); - expect(stdout).toContain( + expect(stdout).toContainText('File: rule_test.json'); + expect(stdout).toContainText( 'Failed to parse JSON file' + EOL + ` Path: ${pathLib.join('iac', 'arm', 'invalid_rule_test.json')}`, ); - expect(stdout).toContain( + expect(stdout).toContainText( 'Could not find any valid IaC files' + EOL + ` Path: non-existing-dir`, ); expect(exitCode).toBe(1); @@ -240,10 +241,12 @@ describe('Directory scan', () => { `snyk iac test ./iac/arm non-existing-dir --json`, ); expect(isValidJSONString(stdout)).toBe(true); - expect(stdout).toContain('"id": "SNYK-CC-TF-20",'); - expect(stdout).toContain('"ok": false'); - expect(stdout).toContain('"error": "Could not find any valid IaC files"'); - expect(stdout).toContain('"path": "non-existing-dir"'); + expect(stdout).toContainText('"id": "SNYK-CC-TF-20",'); + expect(stdout).toContainText('"ok": false'); + expect(stdout).toContainText( + '"error": "Could not find any valid IaC files"', + ); + expect(stdout).toContainText('"path": "non-existing-dir"'); expect(exitCode).toBe(3); }); @@ -265,21 +268,8 @@ describe('Directory scan', () => { const { stdout, exitCode } = await run( `snyk iac test ./iac/only-invalid`, ); - expect(stdout).toContain( - ' Failed to parse YAML file' + - EOL + - ` Path: ${pathLib.join( - 'iac', - 'only-invalid', - 'invalid-file1.yml', - )}` + - EOL + - ` ${pathLib.join( - 'iac', - 'only-invalid', - 'invalid-file2.yaml', - )}`, - ); + const filePath = pathLib.join('iac', 'only-invalid', 'invalid-file1.yml'); + expect(stdout).toContainText(new InvalidYamlFileError(filePath).message); expect(exitCode).toBe(2); }); it('prints all errors and paths in --json', async () => { @@ -290,8 +280,8 @@ describe('Directory scan', () => { expect(isValidJSONString(stdout)).toBe(true); expect(JSON.parse(stdout).length).toBe(2); - expect(stdout).toContain('"ok": false'); - expect(stdout).toContain('"error": "Failed to parse YAML file"'); + expect(stdout).toContainText('"ok": false'); + expect(stdout).toContainText('"error": "Failed to parse YAML file"'); expect(exitCode).toBe(2); }); }); @@ -303,7 +293,7 @@ describe('Directory scan', () => { `snyk iac test ./iac/kubernetes/`, ); expect(stderr).toBe(''); - expect(stdout).toContain( + expect(stdout).toContainText( '✔ Files without issues: 0' + EOL + '✗ Files with issues: 3', ); expect(exitCode).toBe(1); @@ -313,7 +303,7 @@ describe('Directory scan', () => { `snyk iac test ./iac/kubernetes/ --json`, ); expect(stderr).toBe(''); - expect(stdout).toContain('"ok": false'); + expect(stdout).toContainText('"ok": false'); expect(exitCode).toBe(1); }); @@ -332,7 +322,7 @@ describe('Directory scan', () => { `snyk iac test ./iac/no_vulnerabilities/ --severity-threshold=high`, ); expect(stderr).toBe(''); - expect(stdout).toContain('No vulnerable paths were found!'); + expect(stdout).toContainText('No vulnerable paths were found!'); expect(exitCode).toBe(0); }); @@ -341,7 +331,7 @@ describe('Directory scan', () => { `snyk iac test ./iac/no_vulnerabilities/ --severity-threshold=high --json`, ); expect(stderr).toBe(''); - expect(stdout).toContain('"ok": true'); + expect(stdout).toContainText('"ok": true'); expect(exitCode).toBe(0); }); @@ -350,7 +340,7 @@ describe('Directory scan', () => { `snyk iac test ./iac/no_vulnerabilities/ --severity-threshold=high --sarif`, ); expect(stderr).toBe(''); - expect(stdout).toContain('"results": []'); + expect(stdout).toContainText('"results": []'); expect(exitCode).toBe(0); }); }); diff --git a/test/jest/acceptance/iac/test-kubernetes.spec.ts b/test/jest/acceptance/iac/test-kubernetes.spec.ts index ef1b91cc35..68dbb4addd 100644 --- a/test/jest/acceptance/iac/test-kubernetes.spec.ts +++ b/test/jest/acceptance/iac/test-kubernetes.spec.ts @@ -1,5 +1,7 @@ import { EOL } from 'os'; import { startMockServer, isValidJSONString } from './helpers'; +import { NoFilesToScanError } from '../../../../src/cli/commands/test/iac/local-execution/file-loader'; +import { InvalidYamlFileError } from '../../../../src/cli/commands/test/iac/local-execution/yaml-parser'; jest.setTimeout(50000); @@ -45,11 +47,7 @@ describe('Kubernetes single file scan', () => { const { stdout, exitCode } = await run( `snyk iac test ./iac/kubernetes/pod-invalid.yaml`, ); - expect(stdout).toContain( - 'Could not find any valid IaC files' + - EOL + - ' Path: ./iac/kubernetes/pod-invalid.yaml', - ); + expect(stdout).toContainText(new NoFilesToScanError().message); expect(exitCode).toBe(3); }); @@ -77,10 +75,9 @@ describe('Kubernetes single file scan', () => { }); it('outputs an error for Helm files', async () => { - const { stdout, exitCode } = await run( - `snyk iac test ./iac/kubernetes/helm-config.yaml`, - ); - expect(stdout).toContain('Failed to parse YAML file'); + const path = './iac/kubernetes/helm-config.yaml'; + const { stdout, exitCode } = await run(`snyk iac test ${path}`); + expect(stdout).toContainText(new InvalidYamlFileError(path).message); expect(exitCode).toBe(2); }); }); diff --git a/test/jest/acceptance/iac/test-terraform.spec.ts b/test/jest/acceptance/iac/test-terraform.spec.ts index 17bbff4f5f..1d934e8e18 100644 --- a/test/jest/acceptance/iac/test-terraform.spec.ts +++ b/test/jest/acceptance/iac/test-terraform.spec.ts @@ -2,6 +2,8 @@ import { isValidJSONString, startMockServer } from './helpers'; import * as path from 'path'; import { EOL } from 'os'; import { FakeServer } from '../../../acceptance/fake-server'; +import { FailedToParseTerraformFileError } from '../../../../src/cli/commands/test/iac/local-execution/parsers/terraform-file-parser'; +import { InvalidVarFilePath } from '../../../../src/cli/commands/test/iac/local-execution'; jest.setTimeout(50000); @@ -48,13 +50,10 @@ describe('Terraform', () => { }); it('outputs an error for files with invalid HCL2', async () => { - const { stdout, exitCode } = await run( - `snyk iac test ./iac/terraform/sg_open_ssh_invalid_hcl2.tf`, - ); - expect(stdout).toContain( - 'Failed to parse Terraform file' + - EOL + - ' Path: ./iac/terraform/sg_open_ssh_invalid_hcl2.tf', + const path = './iac/terraform/sg_open_ssh_invalid_hcl2.tf'; + const { stdout, exitCode } = await run(`snyk iac test ${path}`); + expect(stdout).toContainText( + new FailedToParseTerraformFileError(path).message, ); expect(exitCode).toBe(2); }); @@ -207,14 +206,11 @@ describe('Terraform', () => { expect(exitCode).toBe(1); }); it('returns error if the file does not exist', async () => { + const path = './iac/terraform/non-existent.tfvars'; const { stdout, exitCode } = await run( - `snyk iac test ./iac/terraform/var_deref --var-file=./iac/terraform/non-existent.tfvars`, - ); - expect(stdout).toContain( - 'Invalid path to variable definitions file' + - EOL + - ' Path: ./iac/terraform/var_deref', + `snyk iac test ./iac/terraform/var_deref --var-file=${path}`, ); + expect(stdout).toContainText(new InvalidVarFilePath(path).message); expect(exitCode).toBe(2); }); it('will not parse the external file if it is invalid', async () => { diff --git a/test/jest/acceptance/iac/update-exclude-policy.spec.ts b/test/jest/acceptance/iac/update-exclude-policy.spec.ts index 44dd46c035..39e859feee 100644 --- a/test/jest/acceptance/iac/update-exclude-policy.spec.ts +++ b/test/jest/acceptance/iac/update-exclude-policy.spec.ts @@ -31,7 +31,7 @@ describe('iac update-exclude-policy', () => { {}, ); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Command "update-exclude-policy" is currently not supported for this org. To enable it, please contact snyk support.', ); expect(stderr).toMatch(''); diff --git a/test/jest/acceptance/snyk-fix/fix.spec.ts b/test/jest/acceptance/snyk-fix/fix.spec.ts index cabc3898f8..425ab2b8fb 100644 --- a/test/jest/acceptance/snyk-fix/fix.spec.ts +++ b/test/jest/acceptance/snyk-fix/fix.spec.ts @@ -57,7 +57,7 @@ describe('snyk fix', () => { env, }); expect(code).toEqual(2); - expect(stdout).toMatch( + expect(stdout).toContainText( "`snyk fix` is not supported for org 'no-flag'.\nSee documentation on how to enable this beta feature: https://docs.snyk.io/snyk-cli/fix-vulnerabilities-from-the-cli/automatic-remediation-with-snyk-fix#enabling-snyk-fix", ); expect(stderr).toBe(''); diff --git a/test/jest/acceptance/snyk-test/all-projects.spec.ts b/test/jest/acceptance/snyk-test/all-projects.spec.ts index fdc4fd6433..7c383341c5 100644 --- a/test/jest/acceptance/snyk-test/all-projects.spec.ts +++ b/test/jest/acceptance/snyk-test/all-projects.spec.ts @@ -78,7 +78,7 @@ describe('snyk test --all-projects (mocked server only)', () => { ); expect(code).toEqual(2); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Your test request could not be completed.\nTip: Re-run in debug mode to see more information: DEBUG=*snyk* \nIf the issue persists contact support@snyk.io', ); expect(stderr).toMatch( diff --git a/test/jest/acceptance/snyk-test/yarn-workspaces.spec.ts b/test/jest/acceptance/snyk-test/yarn-workspaces.spec.ts index e01d03e2d1..18c4b5db6c 100644 --- a/test/jest/acceptance/snyk-test/yarn-workspaces.spec.ts +++ b/test/jest/acceptance/snyk-test/yarn-workspaces.spec.ts @@ -51,7 +51,7 @@ describe('snyk test --yarn-workspaces (mocked server only)', () => { expect(code).toEqual(2); - expect(stdout).toMatch( + expect(stdout).toContainText( 'Your package.json and yarn.lock are probably out of sync', ); expect(stderr).toEqual(''); diff --git a/test/jest/util/runSnykCLI.ts b/test/jest/util/runSnykCLI.ts index 88eccb3cee..52bcc7326c 100644 --- a/test/jest/util/runSnykCLI.ts +++ b/test/jest/util/runSnykCLI.ts @@ -25,3 +25,19 @@ const runSnykCLIWithArray = async ( }; export { runSnykCLI, runSnykCLIWithArray }; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Matchers { + /** + * Assert that the received text includes the expected substring if whitespace is omited. + * @param received The text to assert. + * @param expected The substring to match. + * @returns {boolean} + */ + toContainText(expected: string): CustomMatcherResult; + } + } +} diff --git a/test/jest/util/startSnykCLI.ts b/test/jest/util/startSnykCLI.ts index 3aead598e5..1b1324cc44 100644 --- a/test/jest/util/startSnykCLI.ts +++ b/test/jest/util/startSnykCLI.ts @@ -44,7 +44,8 @@ const createMatchableOutput = (outputStream: Readable) => { const matches = typeof expected === 'string' - ? () => output.includes(expected) + ? () => + output.replace(/\s/g, '').includes(expected.replace(/\s/g, '')) : () => expected.test(output); const matcher = (): void => { diff --git a/test/setup-jest.ts b/test/setup-jest.ts new file mode 100644 index 0000000000..4b88d96eb9 --- /dev/null +++ b/test/setup-jest.ts @@ -0,0 +1,12 @@ +expect.extend({ + toContainText(received: string, expected: string) { + const [cleanReceived, cleanExpected] = [received, expected].map((t) => + t.replace(/\s/g, ''), + ); + const pass = cleanReceived.includes(cleanExpected); + return { + pass, + message: () => `expected "${received}" to contain "${expected}".`, + }; + }, +});