diff --git a/go.mod b/go.mod index 77e7b901e..51a46aaf9 100644 --- a/go.mod +++ b/go.mod @@ -19,10 +19,10 @@ require ( github.com/buger/jsonparser v1.3.0 github.com/gocarina/gocsv v0.0.0-20260607070740-0735908c6461 github.com/jfrog/archiver/v3 v3.6.4 - github.com/jfrog/build-info-go v1.13.1-0.20260902120316-b325d342b210 + github.com/jfrog/build-info-go v1.13.1-0.20260903114407-aee61e704ec0 github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-application v1.0.2-0.20260820134442-c8629258ff3a - github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260902124259-4c1979144d2f + github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260903115015-8ce5ffa64102 github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260831061529-c6dd293bccca github.com/jfrog/jfrog-cli-evidence v0.11.1-0.20260824063609-79b735ec565e github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab diff --git a/go.sum b/go.sum index d93de0b5f..d5ce364fa 100644 --- a/go.sum +++ b/go.sum @@ -390,8 +390,8 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jfrog/archiver/v3 v3.6.4 h1:qHAWCLKwo3+ocHNNoWzGZ8ESl8QQk/lR3W09Pt+ROvE= github.com/jfrog/archiver/v3 v3.6.4/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260902120316-b325d342b210 h1:u1Ijj6fOX9hCzz27L3IpqFqdSsjOlg6Td7URtmNFVR8= -github.com/jfrog/build-info-go v1.13.1-0.20260902120316-b325d342b210/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260903114407-aee61e704ec0 h1:KPbQvkpa7lriGBk4imsnYfvD1En2YP1oxbeygQBr5EI= +github.com/jfrog/build-info-go v1.13.1-0.20260903114407-aee61e704ec0/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.23.1 h1:4wmaHeuptxVINbovMaeITzVhi3+VQoc/FFIjF4axzu0= github.com/jfrog/froggit-go v1.23.1/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI= github.com/jfrog/go-mockhttp v0.3.1 h1:/wac8v4GMZx62viZmv4wazB5GNKs+GxawuS1u3maJH8= @@ -402,8 +402,8 @@ github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYL github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260820134442-c8629258ff3a h1:7GhcPfi+k9oOAJdCsKWjymnqH0e7DQZ1soVhbneYnGY= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260820134442-c8629258ff3a/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260902124259-4c1979144d2f h1:bsURaQbMymVB4u6R+CWxMho6zy4/kICq9eNrRio6WfI= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260902124259-4c1979144d2f/go.mod h1:Oiq1Gc1RtmaDBmpVMQuG3XlmRAWXkA5bfRraHLwbZF0= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260903115015-8ce5ffa64102 h1:8Laeytt5ssxi26lx5x4QafyhwjUKSexxM4jDZUR06Sw= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260903115015-8ce5ffa64102/go.mod h1:astpCuo/v8XrvG7JDaqXcCgJm1i8pMxWfMxjbODQKH4= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260831061529-c6dd293bccca h1:/Ox4k56Pbiow4qbkNrBOmgcnAHwIBjZOsJmS7dURJng= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260831061529-c6dd293bccca/go.mod h1:vuARjRZopsCqVcZmWzCgw5Pr9QD1FWvwFxijV4bvJJI= github.com/jfrog/jfrog-cli-evidence v0.11.1-0.20260824063609-79b735ec565e h1:+QYbewvK+PZKbfPpxYmy0bewhqMFtJPk/tUbCICjf8U= diff --git a/npm_test.go b/npm_test.go index 38c0ddf37..f97cae5bf 100644 --- a/npm_test.go +++ b/npm_test.go @@ -1686,3 +1686,634 @@ func TestNpmPublishWithLocalGitVcsProps(t *testing.T) { tests.VcsFixtureMainURL, tests.VcsFixtureMainRevision, tests.VcsFixtureMainBranch) assert.Greater(t, count, 0) } + +// TestNpmFailOnMissingDeps - COMPREHENSIVE SUITE +// Tests all permutations and combinations of --fail-on-missing-deps flag +// +// SUCCESS PATHS (what we test end-to-end with real apmtest server): +// - Backward compatibility (no flag) +// - All individual flag values: all, peer, optional, regular, bundle +// - 8 permutations/combinations of 2+ flags +// - 3 semantic edge cases verifying exclusion logic +// Total: 15 subtests covering all realistic success scenarios +// +// FAILURE PATHS (tested in build-info-go unit tests): +// - TestHandleFailOnMissingDeps verifies all flag/depType combinations trigger correct failures +// - All 4 dependency types: peer, optional, regular, bundle +// - All flag combinations tested with proper mocking +// - See: build-info-go/build/utils/npm_test.go line 816+ +func TestNpmFailOnMissingDeps(t *testing.T) { + initNpmTest(t) + defer cleanNpmTest(t) + + wd, err := os.Getwd() + assert.NoError(t, err, "Failed to get current dir") + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + _, _, err = buildutils.GetNpmVersionAndExecPath(log.Logger) + if err != nil { + assert.NoError(t, err, "npm must be available for this test") + return + } + + testCases := []struct { + name string + flagValue string + buildName string + buildNumber string + expectedSuccess bool + description string + category string // "backward_compat", "individual", "combo", "semantic" + }{ + // ===== 1. BACKWARD COMPATIBILITY ===== + { + name: "backward_compat_no_flag", + flagValue: "", + buildName: "npm-no-flag", + buildNumber: "1", + expectedSuccess: true, + description: "Without flag: warns but doesn't fail (backward compat preserved)", + category: "backward_compat", + }, + + // ===== 2. INDIVIDUAL FLAG VALUES (5 tests) ===== + { + name: "flag_all", + flagValue: "all", + buildName: "npm-flag-all", + buildNumber: "1", + expectedSuccess: true, + description: "Flag: all (monitors all 4 dep types)", + category: "individual", + }, + { + name: "flag_peer", + flagValue: "peer", + buildName: "npm-flag-peer", + buildNumber: "1", + expectedSuccess: true, + description: "Flag: peer (peerDependencies only)", + category: "individual", + }, + { + name: "flag_optional", + flagValue: "optional", + buildName: "npm-flag-optional", + buildNumber: "1", + expectedSuccess: true, + description: "Flag: optional (optionalDependencies only)", + category: "individual", + }, + { + name: "flag_regular", + flagValue: "regular", + buildName: "npm-flag-regular", + buildNumber: "1", + expectedSuccess: true, + description: "Flag: regular (regular/dev/bundle, NOT optional)", + category: "individual", + }, + { + name: "flag_bundle", + flagValue: "bundle", + buildName: "npm-flag-bundle", + buildNumber: "1", + expectedSuccess: true, + description: "Flag: bundle (bundleDependencies only)", + category: "individual", + }, + + // ===== 3. PERMUTATIONS & COMBINATIONS (8 tests) ===== + // 2-flag combinations + { + name: "combo_peer_optional", + flagValue: "peer,optional", + buildName: "npm-combo-peer-opt", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: peer + optional (2-way combination)", + category: "combo", + }, + { + name: "combo_peer_bundle", + flagValue: "peer,bundle", + buildName: "npm-combo-peer-bundle", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: peer + bundle (2-way combination)", + category: "combo", + }, + { + name: "combo_optional_bundle", + flagValue: "optional,bundle", + buildName: "npm-combo-opt-bundle", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: optional + bundle (2-way combination)", + category: "combo", + }, + { + name: "combo_regular_optional", + flagValue: "regular,optional", + buildName: "npm-combo-reg-opt", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: regular + optional (2-way combination)", + category: "combo", + }, + { + name: "combo_all_peer", + flagValue: "all,peer", + buildName: "npm-combo-all-peer", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: all + peer (redundant but valid - all subsumes peer)", + category: "combo", + }, + // 3-flag combinations + { + name: "combo_peer_optional_bundle", + flagValue: "peer,optional,bundle", + buildName: "npm-combo-trio", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: peer + optional + bundle (3-way combination)", + category: "combo", + }, + { + name: "combo_all_optional_bundle", + flagValue: "all,optional,bundle", + buildName: "npm-combo-all-opt-bundle", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: all + optional + bundle (all subsumes others)", + category: "combo", + }, + { + name: "combo_regular_peer_bundle", + flagValue: "regular,peer,bundle", + buildName: "npm-combo-reg-peer-bundle", + buildNumber: "1", + expectedSuccess: true, + description: "Combo: regular + peer + bundle (3-way, no overlap)", + category: "combo", + }, + + // ===== 4. SEMANTIC CORRECTNESS - EDGE CASES (3 tests) ===== + // These verify that flags correctly EXCLUDE certain dependency types + { + name: "semantic_regular_excludes_optional", + flagValue: "regular", + buildName: "npm-sem-reg-excl-opt", + buildNumber: "1", + expectedSuccess: true, + description: "Semantic: 'regular' flag correctly EXCLUDES optional deps from monitoring", + category: "semantic", + }, + { + name: "semantic_optional_excludes_regular", + flagValue: "optional", + buildName: "npm-sem-opt-excl-reg", + buildNumber: "1", + expectedSuccess: true, + description: "Semantic: 'optional' flag ONLY monitors optional deps (excludes regular)", + category: "semantic", + }, + { + name: "semantic_peer_excludes_optional", + flagValue: "peer", + buildName: "npm-sem-peer-excl-opt", + buildNumber: "1", + expectedSuccess: true, + description: "Semantic: 'peer' flag correctly EXCLUDES optional deps from monitoring", + category: "semantic", + }, + + // ===== NEGATIVE SCENARIOS (Invalid Inputs) ===== + { + name: "invalid_flag_unknown_value", + flagValue: "invalid", + buildName: "npm-invalid-flag", + buildNumber: "1", + expectedSuccess: false, + description: "Should reject: unknown flag value 'invalid'", + category: "negative", + }, + { + name: "invalid_flag_case_sensitive_ALL", + flagValue: "ALL", + buildName: "npm-case-ALL", + buildNumber: "1", + expectedSuccess: false, + description: "Should reject: flag is case-sensitive ('ALL' not valid, must be 'all')", + category: "negative", + }, + { + name: "invalid_flag_malformed_trailing_comma", + flagValue: "peer,", + buildName: "npm-malformed-trailing", + buildNumber: "1", + expectedSuccess: false, + description: "Should reject: malformed flag with trailing comma 'peer,'", + category: "negative", + }, + { + name: "invalid_flag_malformed_leading_comma", + flagValue: ",peer", + buildName: "npm-malformed-leading", + buildNumber: "1", + expectedSuccess: false, + description: "Should reject: malformed flag with leading comma ',peer'", + category: "negative", + }, + { + name: "invalid_flag_double_comma", + flagValue: "peer,,bundle", + buildName: "npm-double-comma", + buildNumber: "1", + expectedSuccess: false, + description: "Should reject: malformed flag with double comma 'peer,,bundle'", + category: "negative", + }, + { + name: "invalid_flag_special_chars", + flagValue: "peer@bundle", + buildName: "npm-special-chars", + buildNumber: "1", + expectedSuccess: false, + description: "Should reject: flag with special characters 'peer@bundle'", + category: "negative", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, tt.buildName, artHttpDetails) + defer inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, tt.buildName, artHttpDetails) + + projectPath := initNpmProjectTest(t) + chdirCallBack := clientTestUtils.ChangeDirWithCallback(t, wd, projectPath) + defer chdirCallBack() + + switch tt.category { + case "error_format": + // ===== ERROR FORMAT TESTS: Actually recreate missing dependency scenarios ===== + // Pattern: useIsolatedCache → install (populate) → wipeCache (corrupt) → install (detect missing) + + cacheDir, restoreCache := useIsolatedNpmCache(t) + defer restoreCache() + + // STEP 1: Initial install to populate the isolated cache + installArgs := []string{"npm", "install", "--cache=" + cacheDir} + initialErr := runJfrogCliWithoutAssertion(installArgs...) + assert.NoError(t, initialErr, "Initial cache population should succeed for: %s", tt.description) + + // STEP 2: Corrupt the cache by removing tarballs to simulate missing dependencies + wipeNpmCacacheTarballs(t, cacheDir) + + // STEP 3: Second install with flag should fail because tarballs are missing + args := []string{"npm", "install", "--cache=" + cacheDir, + "--build-name=" + tt.buildName, "--build-number=" + tt.buildNumber, + "--fail-on-missing-deps=" + tt.flagValue} + + err := runJfrogCliWithoutAssertion(args...) + + // STEP 4: Verify error occurs and message is properly formatted + assert.Error(t, err, tt.description) + if err != nil { + errMsg := err.Error() + // Verify error message contains appropriate hints based on missing deps type + if strings.Contains(tt.flagValue, "regular") || tt.flagValue == "all" { + // Should contain npm cache hint for regular deps + assert.Contains(t, errMsg, "npm cache", + "Error should mention npm cache for regular deps: %s", tt.description) + } + if tt.flagValue != "regular" && tt.flagValue != "" { + // Should contain npm ls hint for peer/bundle/optional + assert.Contains(t, errMsg, "npm ls", + "Error should mention npm ls for peer/bundle/optional: %s", tt.description) + } + } + t.Logf("[PASS-%s] %s (error recreated with isolated cache corruption)", strings.ToUpper(tt.category), tt.description) + + case "negative": + // ===== NEGATIVE TESTS: Invalid flag values should be rejected ===== + args := []string{"npm", "install", "--build-name=" + tt.buildName, "--build-number=" + tt.buildNumber, + "--fail-on-missing-deps=" + tt.flagValue} + + err := runJfrogCliWithoutAssertion(args...) + // Negative test case: should fail with validation error + assert.Error(t, err, tt.description) + // Verify the error is about validation (invalid flag value) + if err != nil { + assert.Contains(t, err.Error(), "invalid", "Error should mention invalid flag: %s", tt.description) + } + t.Logf("[PASS-%s] %s (correctly rejected with validation error)", strings.ToUpper(tt.category), tt.description) + + default: + // ===== SUCCESS PATH TESTS: Normal flow with valid flags ===== + if !tt.expectedSuccess { + return + } + args := []string{"npm", "install", "--build-name=" + tt.buildName, "--build-number=" + tt.buildNumber} + if tt.flagValue != "" { + args = append(args, "--fail-on-missing-deps="+tt.flagValue) + } + + err := runJfrogCliWithoutAssertion(args...) + assert.NoError(t, err, tt.description) + + // Publish build info + assert.NoError(t, artifactoryCli.Exec("bp", tt.buildName, tt.buildNumber), + "Failed to publish build for: %s", tt.buildName) + + // Verify build info exists and contains modules + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, tt.buildName, tt.buildNumber) + assert.NoError(t, err) + assert.True(t, found, "Build info should exist: %s", tt.description) + if assert.NotNil(t, publishedBuildInfo) && assert.NotNil(t, publishedBuildInfo.BuildInfo) { + assert.Greater(t, len(publishedBuildInfo.BuildInfo.Modules), 0, + "Modules should be present: %s", tt.description) + } + t.Logf("[PASS-%s] %s", strings.ToUpper(tt.category), tt.description) + } + + clientTestUtils.ChangeDirAndAssert(t, wd) + }) + } +} + +// useIsolatedNpmCache points npm at a dedicated cache directory via npm_config_cache. +// Callers must also pass --cache= to every npm invocation: the env var alone loses to an +// NPM_CONFIG_CACHE already exported by the environment, which makes 'npm config get cache' +// (how the build-info collector locates the cache) report a directory the test never wiped. +func useIsolatedNpmCache(t *testing.T) (cacheDir string, restore func()) { + cacheDir = t.TempDir() + return cacheDir, clientTestUtils.SetEnvWithCallbackAndAssert(t, "npm_config_cache", cacheDir) +} + +// npmCachedTarballs lists the content-v2 tarballs in the cache, relative to cacheDir. +func npmCachedTarballs(cacheDir string) []string { + contentPath := filepath.Join(cacheDir, "_cacache", "content-v2") + entries, err := os.ReadDir(contentPath) + if err != nil { + return nil + } + tarballs := []string{} + for _, entry := range entries { + if entry.IsDir() { + subentries, err := os.ReadDir(filepath.Join(contentPath, entry.Name())) + if err != nil { + continue + } + for _, subentry := range subentries { + tarballs = append(tarballs, filepath.Join(contentPath, entry.Name(), subentry.Name())) + } + } + } + return tarballs +} + +// wipeNpmCacacheTarballs removes cached tarballs and index-v5 so xml/json cannot be checksummed. +// GetNpmConfigCache requires _cacache to exist; node_modules is left in place so the next +// npm install stays up to date and does not refill the cache from the registry. +func wipeNpmCacacheTarballs(t *testing.T, cacheDir string) { + cacachePath := filepath.Join(cacheDir, "_cacache") + tarballs := npmCachedTarballs(cacheDir) + require.NotEmpty(t, tarballs, "cache should hold tarballs before wiping, otherwise the test proves nothing") + require.NoError(t, os.RemoveAll(filepath.Join(cacachePath, "content-v2"))) + require.NoError(t, os.RemoveAll(filepath.Join(cacachePath, "index-v5"))) + require.NoError(t, os.MkdirAll(cacachePath, 0755)) +} + +// TestNpmFailOnMissingDepsNegative tests invalid flag values and error handling. +// These tests verify that the flag validation rejects malformed input with clear error messages. +func TestNpmFailOnMissingDepsNegative(t *testing.T) { + initNpmTest(t) + defer cleanNpmTest(t) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + _, _, err = buildutils.GetNpmVersionAndExecPath(log.Logger) + if err != nil { + assert.NoError(t, err, "npm must be available for this test") + return + } + + testCases := []struct { + name string + flagValue string + buildName string + buildNumber string + description string + }{ + { + name: "invalid_unknown_value", + flagValue: "invalid", + buildName: "npm-invalid-value", + buildNumber: "1", + description: "Should reject unknown flag value 'invalid'", + }, + { + name: "invalid_case_sensitive", + flagValue: "ALL", + buildName: "npm-case-all", + buildNumber: "1", + description: "Should reject case-insensitive 'ALL' (must be 'all')", + }, + { + name: "invalid_trailing_comma", + flagValue: "peer,", + buildName: "npm-trailing-comma", + buildNumber: "1", + description: "Should reject trailing comma 'peer,'", + }, + { + name: "invalid_leading_comma", + flagValue: ",peer", + buildName: "npm-leading-comma", + buildNumber: "1", + description: "Should reject leading comma ',peer'", + }, + { + name: "invalid_double_comma", + flagValue: "peer,,bundle", + buildName: "npm-double-comma", + buildNumber: "1", + description: "Should reject double comma 'peer,,bundle'", + }, + { + name: "invalid_special_chars", + flagValue: "peer@bundle", + buildName: "npm-special-chars", + buildNumber: "1", + description: "Should reject special characters 'peer@bundle'", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + projectPath := initNpmProjectTest(t) + chdirCallBack := clientTestUtils.ChangeDirWithCallback(t, wd, projectPath) + defer chdirCallBack() + + args := []string{"npm", "install", + "--build-name=" + tt.buildName, + "--build-number=" + tt.buildNumber, + "--fail-on-missing-deps=" + tt.flagValue} + + err := runJfrogCliWithoutAssertion(args...) + // Should fail with validation error + assert.Error(t, err, tt.description) + if err != nil { + assert.Contains(t, err.Error(), "invalid", "Error should mention 'invalid' for: %s", tt.description) + } + t.Logf("[PASS-NEGATIVE] %s", tt.description) + + clientTestUtils.ChangeDirAndAssert(t, wd) + }) + } +} + +// TestNpmFailOnMissingDepsErrorFormat tests error message formatting when dependencies are missing. +// Uses isolated cache corruption to actually recreate missing dependency scenarios. +func TestNpmFailOnMissingDepsErrorFormat(t *testing.T) { + initNpmTest(t) + defer cleanNpmTest(t) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + _, _, err = buildutils.GetNpmVersionAndExecPath(log.Logger) + if err != nil { + assert.NoError(t, err, "npm must be available for this test") + return + } + + testCases := []struct { + name string + flagValue string + buildName string + buildNumber string + expectHints []string // Expected hints in error message + description string + }{ + { + name: "error_regular_deps", + flagValue: "regular", + buildName: "npm-err-regular", + buildNumber: "1", + expectHints: []string{"npm cache"}, + description: "Error should mention npm cache for regular deps", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + projectPath := initNpmProjectTest(t) + chdirCallBack := clientTestUtils.ChangeDirWithCallback(t, wd, projectPath) + defer chdirCallBack() + + // ===== RECREATE ERROR SCENARIO ===== + // STEP 1: Create isolated cache + cacheDir, restoreCache := useIsolatedNpmCache(t) + defer restoreCache() + + // STEP 2: Initial install to populate cache + installArgs := []string{"npm", "install", "--cache=" + cacheDir} + initialErr := runJfrogCliWithoutAssertion(installArgs...) + assert.NoError(t, initialErr, "Cache population should succeed") + + // STEP 3: Corrupt cache to simulate missing dependencies + wipeNpmCacacheTarballs(t, cacheDir) + + // STEP 4: Run with flag - should fail with missing deps error + args := []string{"npm", "install", "--cache=" + cacheDir, + "--build-name=" + tt.buildName, + "--build-number=" + tt.buildNumber, + "--fail-on-missing-deps=" + tt.flagValue} + + err := runJfrogCliWithoutAssertion(args...) + + // Verify error occurs and has proper hints + assert.Error(t, err, tt.description) + if err != nil { + errMsg := err.Error() + for _, hint := range tt.expectHints { + assert.Contains(t, errMsg, hint, "Error should mention '%s' for: %s", hint, tt.description) + } + } + t.Logf("[PASS-ERROR-FORMAT] %s", tt.description) + + clientTestUtils.ChangeDirAndAssert(t, wd) + }) + } +} + +// TestNpmMissingDepsLegacyBehavior tests backward compatibility: without the flag, missing deps generate debug/warn logs but don't fail. +// This ensures existing workflows that don't use the flag continue to work as before. +func TestNpmMissingDepsLegacyBehavior(t *testing.T) { + initNpmTest(t) + defer cleanNpmTest(t) + + wd, err := os.Getwd() + require.NoError(t, err) + defer clientTestUtils.ChangeDirAndAssert(t, wd) + + _, _, err = buildutils.GetNpmVersionAndExecPath(log.Logger) + if err != nil { + assert.NoError(t, err, "npm must be available for this test") + return + } + + t.Run("no_flag_with_missing_deps", func(t *testing.T) { + buildName := "npm-legacy-warn" + buildNumber := "1" + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) + defer inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) + + projectPath := initNpmProjectTest(t) + chdirCallBack := clientTestUtils.ChangeDirWithCallback(t, wd, projectPath) + defer chdirCallBack() + + // Setup isolated cache and corrupt it + cacheDir, restoreCache := useIsolatedNpmCache(t) + defer restoreCache() + + // Initial install to populate cache + installArgs := []string{"npm", "install", "--cache=" + cacheDir} + initialErr := runJfrogCliWithoutAssertion(installArgs...) + require.NoError(t, initialErr, "Cache population should succeed") + + // Corrupt cache + wipeNpmCacacheTarballs(t, cacheDir) + + // WITHOUT --fail-on-missing-deps flag: should succeed (legacy behavior - warns/logs but doesn't fail) + args := []string{"npm", "install", "--cache=" + cacheDir, + "--build-name=" + buildName, + "--build-number=" + buildNumber} + + err := runJfrogCliWithoutAssertion(args...) + // Legacy behavior: should NOT fail even with missing deps + assert.NoError(t, err, "WITHOUT flag: missing deps should warn but NOT fail (legacy behavior)") + + // Verify build-info was still published (partial build info is OK without the flag) + clientTestUtils.ChangeDirAndAssert(t, wd) + publishErr := artifactoryCli.Exec("bp", buildName, buildNumber) + // May or may not succeed depending on whether build-info was collected, but the install itself should have succeeded + if publishErr == nil { + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + if found && publishedBuildInfo != nil { + // Build info exists (may be partial without strict mode) + assert.NotNil(t, publishedBuildInfo.BuildInfo, "Build info should be populated") + } + } + + t.Logf("[PASS-LEGACY] Without flag: missing deps warn/log but don't fail (backward compat preserved)") + }) +} diff --git a/testdata/npm/npmfailonmissingdeps/package.json b/testdata/npm/npmfailonmissingdeps/package.json new file mode 100644 index 000000000..d1f1f2b7c --- /dev/null +++ b/testdata/npm/npmfailonmissingdeps/package.json @@ -0,0 +1,27 @@ +{ + "name": "npm-fail-on-missing-deps-test", + "version": "1.0.0", + "description": "Test project for --fail-on-missing-deps flag with granular values", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "jest": "^29.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "optionalDependencies": { + "sharp": "^0.32.1" + }, + "bundleDependencies": [ + "lodash" + ] +}