diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 19b441f9..bc3a58de 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -34,7 +34,7 @@ jobs: - name: Find tests not covered by any named group id: find-uncovered run: | - # Combined regex of every pattern used across the 12 named matrix groups. + # Combined regex of every pattern used across the 13 named matrix groups. # Any test whose name does NOT match this will land in the catch-all group. # Built via concatenation so every line stays at ≥10-space YAML indentation. CP="TestCreateScan|TestScanCreate|TestScansE2E|TestFastScan" @@ -56,6 +56,7 @@ jobs: CP="${CP}|TestGitLab|TestBitbucket|TestBitBucket|TestAzure|TestHooksPreCommit" CP="${CP}|TestGetLearnMore|TestImport|TestGetTenant|TestMaskSecrets|TestFailedMask" CP="${CP}|TestScaRemediation|TestKicsRemediation|TestTelemetry|Test_Handle|TestChat" + CP="${CP}|TestIntegrationScaResolver" COVERED_PATTERNS="${CP}" ALL_TESTS=$(grep -rh "^func Test" test/integration/*_test.go \ @@ -84,8 +85,8 @@ jobs: fi # ───────────────────────────────────────────────────────────────────────────── - # Job B: Run each test group in parallel across 13 matrix entries. - # The 13th entry (uncovered) is a dynamic catch-all driven by Job A. + # Job B: Run each test group in parallel across 14 matrix entries. + # The 14th entry (uncovered) is a dynamic catch-all driven by Job A. # ───────────────────────────────────────────────────────────────────────────── integration-tests: name: Integration Tests (${{ matrix.label }}) @@ -191,7 +192,15 @@ jobs: needs_precommit: "true" run_cleandata: "false" - # 13 ── Catch-All (dynamic; pattern injected at runtime from Job A output) + # 13 ── SCA Resolver (sca_resolver_test.go only; isolated so the real ~114MB ScaResolver download + real scans run exactly once) + - name: sca-resolver + label: "SCA Resolver" + run_pattern: "TestIntegrationScaResolver" + timeout: "45m" + needs_precommit: "false" + run_cleandata: "true" + + # 14 ── Catch-All (dynamic; pattern injected at runtime from Job A output) - name: uncovered label: "Catch-All (Uncovered)" run_pattern: "__UNCOVERED__" diff --git a/test/integration/sca_resolver_test.go b/test/integration/sca_resolver_test.go new file mode 100644 index 00000000..08032891 --- /dev/null +++ b/test/integration/sca_resolver_test.go @@ -0,0 +1,225 @@ +//go:build integration + +package integration + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/checkmarx/ast-cli/internal/commands/scarealtime/scaconfig" + "github.com/checkmarx/ast-cli/internal/params" + "github.com/checkmarx/ast-cli/internal/services/osinstaller" + "gotest.tools/assert" +) + +// Separate from scaconfig.Params.WorkingDirName so it never touches the sca-realtime tests' cache dir. +const scaResolverWorkingDirName = "SCAResolverIntegrationTest" + +var ( + scaResolverOnce sync.Once + scaResolverConfig osinstaller.InstallationConfiguration + scaResolverPath string + scaResolverErr error +) + +// getScaResolverExecutable downloads the real ScaResolver executable once and shares it +// across every test in this file, so the ~114MB download only happens a single time. +func getScaResolverExecutable(t *testing.T) string { + scaResolverOnce.Do(func() { + scaResolverConfig = scaconfig.Params + scaResolverConfig.WorkingDirName = scaResolverWorkingDirName + + _, scaResolverErr = osinstaller.InstallOrUpgrade(&scaResolverConfig, nil) + if scaResolverErr == nil { + scaResolverPath = scaResolverConfig.ExecutableFilePath() + } + }) + + if scaResolverErr != nil { + t.Fatalf("Failed to download ScaResolver executable: %v", scaResolverErr) + } + + return scaResolverPath +} + +// TestIntegrationScaResolverExecutable_Success runs a real +// `cx scan create --sca-resolver ...` using the actual downloaded ScaResolver +// executable, rather than a path pre-staged outside the test. +func TestIntegrationScaResolverExecutable_Success(t *testing.T) { + resolverPath := getScaResolverExecutable(t) + + args := []string{ + "scan", "create", + flag(params.ProjectName), getProjectNameForScanTests(), + flag(params.SourcesFlag), Dir, + flag(params.ScaResolverFlag), resolverPath, + flag(params.ScaResolverParamsFlag), "-q", + flag(params.ScanTypes), "sca", + flag(params.BranchFlag), "dummy_branch", + flag(params.DebugFlag), + } + + err, _ := executeCommand(t, args...) + assert.NilError(t, err) +} + +// Test --no-scan without --sbom-first (in --sca-resolver-params) is rejected with the +// bad-use error and no scan is submitted. +func TestIntegrationScaResolverNoScanWithoutSbomFirst(t *testing.T) { + args := []string{ + "scan", "create", + flag(params.ProjectName), getProjectNameForScanTests(), + flag(params.SourcesFlag), Dir, + flag(params.BranchFlag), "dummy_branch", + flag(params.NoScanFlag), + } + + err, _ := executeCommand(t, args...) + assertError( + t, + err, + "--no-scan flag was passed without --sbom-first: No SBOM was generated and the CxOne scan was skipped. "+ + "Submit --sbom-first under --sca-resolver-params to generate an SBOM.", + ) +} + +// --no-scan + --sbom-first (default location): SBOM saved to /cx-sbom.json, no scan submitted. +func TestIntegrationScaResolverNoScanWithSbomFirst_DefaultLocation(t *testing.T) { + resolverPath := getScaResolverExecutable(t) + + absDir, absErr := filepath.Abs(Dir) + assert.NilError(t, absErr) + expectedSbomPath := filepath.Clean(filepath.Join(absDir, "cx-sbom.json")) + defer func() { _ = os.Remove(expectedSbomPath) }() + + args := []string{ + "scan", "create", + flag(params.ProjectName), getProjectNameForScanTests(), + flag(params.SourcesFlag), Dir, + flag(params.ScaResolverFlag), resolverPath, + flag(params.ScaResolverParamsFlag), "--sbom-first", + flag(params.ScanTypes), "sca", + flag(params.BranchFlag), "dummy_branch", + flag(params.NoScanFlag), + flag(params.DebugFlag), + } + err, _ := executeCommand(t, args...) + assert.NilError(t, err, "scan create with --no-scan + --sbom-first (default location) should succeed") +} + +// --no-scan + --sbom-first with custom --sbom-output-path/--sbom-output-name: SBOM saved to the custom location, no scan submitted. +func TestIntegrationScaResolverNoScanWithSbomFirst_CustomOutputPathAndName(t *testing.T) { + resolverPath := getScaResolverExecutable(t) + + // Subdir under the source dir, not t.TempDir(), to avoid OS temp-folder quirks. + absSourceDir, absErr := filepath.Abs(Dir) + assert.NilError(t, absErr) + outputDir := filepath.Join(absSourceDir, "custom-sbom-output") + assert.NilError(t, os.MkdirAll(outputDir, 0o755)) + defer func() { _ = os.RemoveAll(outputDir) }() + + const customSbomName = "my-project-sbom.json" + expectedSbomPath := filepath.Clean(filepath.Join(outputDir, customSbomName)) + + args := []string{ + "scan", "create", + flag(params.ProjectName), getProjectNameForScanTests(), + flag(params.SourcesFlag), Dir, + flag(params.ScaResolverFlag), resolverPath, + flag(params.ScaResolverParamsFlag), + "--sbom-first --sbom-output-path " + outputDir + " --sbom-output-name " + customSbomName, + flag(params.ScanTypes), "sca", + flag(params.BranchFlag), "dummy_branch", + flag(params.NoScanFlag), + flag(params.DebugFlag), + } + + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + err, _ := executeCommand(t, args...) + assert.NilError(t, err, "scan create with --no-scan + custom sbom output path/name should succeed") + + logText := buf.String() + assert.Assert( + t, + strings.Contains(logText, "Resolved packages information was saved"), + "Expected ScaResolver success message not found in logs", + ) + assert.Assert( + t, + strings.Contains(logText, "SBOM generated and saved to: "+expectedSbomPath), + "Expected SBOM generation confirmation at the custom path/name not found in logs", + ) + assert.Assert( + t, + strings.Contains(logText, "--no-scan set: skipping source compression and upload."), + "Expected source compression/upload skip message not found in logs", + ) + assert.Assert( + t, + strings.Contains(logText, "--no-scan set: skipping scan submission."), + "Expected scan submission skip message not found in logs", + ) + + _, statErr := os.Stat(expectedSbomPath) + assert.NilError(t, statErr, "SBOM file should actually exist at the custom output path/name on disk") +} + +// --sbom-first without --no-scan: SBOM is generated and the CxOne scan still runs normally. +func TestIntegrationScaResolverSbomFirstWithoutNoScan(t *testing.T) { + resolverPath := getScaResolverExecutable(t) + + absDir, absErr := filepath.Abs(Dir) + assert.NilError(t, absErr) + expectedSbomPath := filepath.Clean(filepath.Join(absDir, "cx-sbom.json")) + defer func() { _ = os.Remove(expectedSbomPath) }() + + args := []string{ + "scan", "create", + flag(params.ProjectName), getProjectNameForScanTests(), + flag(params.SourcesFlag), Dir, + flag(params.ScaResolverFlag), resolverPath, + flag(params.ScaResolverParamsFlag), "--sbom-first", + flag(params.ScanTypes), "sca", + flag(params.BranchFlag), "dummy_branch", + flag(params.DebugFlag), + } + + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + err, _ := executeCommand(t, args...) + assert.NilError(t, err, "scan create with --sbom-first (no --no-scan) should succeed and submit a scan") + + logText := buf.String() + assert.Assert( + t, + strings.Contains(logText, "Resolved packages information was saved"), + "Expected ScaResolver success message not found in logs", + ) + assert.Assert( + t, + strings.Contains(logText, "SBOM generated and saved to: "+expectedSbomPath), + "Expected SBOM generation confirmation not found in logs", + ) + assert.Assert( + t, + !strings.Contains(logText, "--no-scan set"), + "scan submission/upload should NOT be skipped when --no-scan is not passed", + ) + + _, statErr := os.Stat(expectedSbomPath) + assert.NilError(t, statErr, "SBOM file should actually exist on disk even though the scan was also submitted") +} diff --git a/test/integration/scan_test.go b/test/integration/scan_test.go index ad25fe3a..70352948 100644 --- a/test/integration/scan_test.go +++ b/test/integration/scan_test.go @@ -1375,7 +1375,6 @@ func TestRunKicsScanWithAdditionalParams(t *testing.T) { } func TestRunScaRealtimeScan(t *testing.T) { - t.Skip("Skip this test cases due to context deadline exceeded") args := []string{scanCommand, "sca-realtime", "--project-dir", projectDirectory} err, _ := executeCommand(t, args...)