Skip to content
17 changes: 13 additions & 4 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 \
Expand Down Expand Up @@ -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 }})
Expand Down Expand Up @@ -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__"
Expand Down
225 changes: 225 additions & 0 deletions test/integration/sca_resolver_test.go
Original file line number Diff line number Diff line change
@@ -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 <path> ...` 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 <source-dir>/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")
}
1 change: 0 additions & 1 deletion test/integration/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
Loading