github.com/google/osv-scalibr@v0.4.1/annotator/misc/npmsource/npmsource_test.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package npmsource_test
    16  
    17  import (
    18  	"os"
    19  	"path/filepath"
    20  	"testing"
    21  
    22  	"github.com/google/go-cmp/cmp"
    23  	"github.com/google/go-cpy/cpy"
    24  	"github.com/google/osv-scalibr/annotator"
    25  	"github.com/google/osv-scalibr/annotator/misc/npmsource"
    26  	"github.com/google/osv-scalibr/extractor"
    27  	"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagejson/metadata"
    28  	scalibrfs "github.com/google/osv-scalibr/fs"
    29  	"github.com/google/osv-scalibr/inventory"
    30  	"google.golang.org/protobuf/proto"
    31  	"google.golang.org/protobuf/testing/protocmp"
    32  )
    33  
    34  func TestAnnotate_AbsolutePackagePath(t *testing.T) {
    35  	copier := cpy.New(
    36  		cpy.Func(proto.Clone),
    37  		cpy.IgnoreAllUnexported(),
    38  	)
    39  
    40  	lockfiles := map[string]string{
    41  		"testproject/package-lock.json": "testdata/package-lock.v1.json",
    42  	}
    43  
    44  	root := setupNPMLockfiles(t, lockfiles)
    45  
    46  	inputPackage := &extractor.Package{
    47  		Name:     "wrappy",
    48  		PURLType: "npm",
    49  		// Locations is the absolute path of the package.json file.
    50  		Locations: []string{filepath.Join(root, "testproject/node_modules/dependency-1/package.json")},
    51  	}
    52  	inv := &inventory.Inventory{Packages: []*extractor.Package{copier.Copy(inputPackage).(*extractor.Package)}}
    53  
    54  	input := &annotator.ScanInput{
    55  		ScanRoot: scalibrfs.RealFSScanRoot(root),
    56  	}
    57  
    58  	wantPackage := &extractor.Package{
    59  		Name:      "wrappy",
    60  		PURLType:  "npm",
    61  		Locations: []string{filepath.Join(root, "testproject/node_modules/dependency-1/package.json")},
    62  		Metadata: &metadata.JavascriptPackageJSONMetadata{
    63  			// We want to assert that the package was resolved from the NPM repository which means that
    64  			// the lockfile was read from the relative path in the scan root.
    65  			Source: metadata.PublicRegistry,
    66  		},
    67  	}
    68  
    69  	err := npmsource.New().Annotate(t.Context(), input, inv)
    70  	if err != nil {
    71  		t.Errorf("Annotate(%v) error: %v; want error presence = false", inputPackage, err)
    72  	}
    73  
    74  	want := &inventory.Inventory{Packages: []*extractor.Package{wantPackage}}
    75  	if diff := cmp.Diff(want, inv, protocmp.Transform()); diff != "" {
    76  		t.Errorf("Annotate(%v): unexpected diff (-want +got):\n%s", inputPackage, diff)
    77  	}
    78  }
    79  
    80  func TestAnnotate_LockfileV1(t *testing.T) {
    81  	copier := cpy.New(
    82  		cpy.Func(proto.Clone),
    83  		cpy.IgnoreAllUnexported(),
    84  	)
    85  
    86  	testCases := []struct {
    87  		name         string
    88  		lockfiles    map[string]string
    89  		inputPackage *extractor.Package
    90  		wantPackage  *extractor.Package
    91  		wantAnyErr   bool
    92  	}{
    93  		{
    94  			name: "unfound_dependency_in_lockfile",
    95  			lockfiles: map[string]string{
    96  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
    97  			},
    98  			inputPackage: &extractor.Package{
    99  				Name:      "abandoned-package",
   100  				PURLType:  "npm",
   101  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   102  			},
   103  			wantPackage: &extractor.Package{
   104  				Name:      "abandoned-package",
   105  				PURLType:  "npm",
   106  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   107  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   108  					Source: metadata.Unknown,
   109  				},
   110  			},
   111  		},
   112  		{
   113  			name: "dependency_from_private_registry",
   114  			lockfiles: map[string]string{
   115  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   116  			},
   117  			inputPackage: &extractor.Package{
   118  				Name:      "supports-color",
   119  				PURLType:  "npm",
   120  				Locations: []string{"testproject/node_modules/supports-color/package.json"},
   121  			},
   122  			wantPackage: &extractor.Package{
   123  				Name:      "supports-color",
   124  				PURLType:  "npm",
   125  				Locations: []string{"testproject/node_modules/supports-color/package.json"},
   126  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   127  					Source: metadata.Other,
   128  				},
   129  			},
   130  		},
   131  		{
   132  			name: "custom_package_from_github_(private_registry)",
   133  			lockfiles: map[string]string{
   134  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   135  			},
   136  			inputPackage: &extractor.Package{
   137  				Name:      "custom-package",
   138  				PURLType:  "npm",
   139  				Locations: []string{"testproject/node_modules/custom-package/package.json"},
   140  			},
   141  			wantPackage: &extractor.Package{
   142  				Name:      "custom-package",
   143  				PURLType:  "npm",
   144  				Locations: []string{"testproject/node_modules/custom-package/package.json"},
   145  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   146  					Source: metadata.Other,
   147  				},
   148  			},
   149  		},
   150  		{
   151  			name: "local_package",
   152  			lockfiles: map[string]string{
   153  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   154  			},
   155  			inputPackage: &extractor.Package{
   156  				Name:      "local-package",
   157  				PURLType:  "npm",
   158  				Locations: []string{"testproject/node_modules/local-package/package.json"},
   159  			},
   160  			wantPackage: &extractor.Package{
   161  				Name:      "local-package",
   162  				PURLType:  "npm",
   163  				Locations: []string{"testproject/node_modules/local-package/package.json"},
   164  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   165  					Source: metadata.Local,
   166  				},
   167  			},
   168  		},
   169  		{
   170  			name: "nested_dependency",
   171  			lockfiles: map[string]string{
   172  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   173  			},
   174  			inputPackage: &extractor.Package{
   175  				Name:      "wrappy",
   176  				PURLType:  "npm",
   177  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   178  			},
   179  			wantPackage: &extractor.Package{
   180  				Name:      "wrappy",
   181  				PURLType:  "npm",
   182  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   183  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   184  					Source: metadata.PublicRegistry,
   185  				},
   186  			},
   187  		},
   188  		{
   189  			name: "alias_package",
   190  			lockfiles: map[string]string{
   191  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   192  			},
   193  			inputPackage: &extractor.Package{
   194  				Name:      "string-width",
   195  				PURLType:  "npm",
   196  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   197  			},
   198  			wantPackage: &extractor.Package{
   199  				Name:      "string-width",
   200  				PURLType:  "npm",
   201  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   202  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   203  					Source: metadata.PublicRegistry,
   204  				},
   205  			},
   206  		},
   207  		{
   208  			name: "duplicated_dependency",
   209  			lockfiles: map[string]string{
   210  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   211  			},
   212  			inputPackage: &extractor.Package{
   213  				Name:      "@babel/highlight",
   214  				PURLType:  "npm",
   215  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   216  			},
   217  			wantPackage: &extractor.Package{
   218  				Name:      "@babel/highlight",
   219  				PURLType:  "npm",
   220  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   221  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   222  					Source: metadata.PublicRegistry,
   223  				},
   224  			},
   225  		},
   226  		{
   227  			name: "same_package_different_group",
   228  			lockfiles: map[string]string{
   229  				"testproject/package-lock.json": "testdata/package-lock.v1.json",
   230  			},
   231  			inputPackage: &extractor.Package{
   232  				Name:      "ajv",
   233  				PURLType:  "npm",
   234  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   235  			},
   236  			wantPackage: &extractor.Package{
   237  				Name:      "ajv",
   238  				PURLType:  "npm",
   239  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   240  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   241  					Source: metadata.PublicRegistry,
   242  				},
   243  			},
   244  		},
   245  		{
   246  			name:      "no lockfile present",
   247  			lockfiles: map[string]string{},
   248  			inputPackage: &extractor.Package{
   249  				Name:      "abandoned-package",
   250  				PURLType:  "npm",
   251  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   252  			},
   253  			wantPackage: &extractor.Package{
   254  				Name:      "abandoned-package",
   255  				PURLType:  "npm",
   256  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   257  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   258  					Source: metadata.Unknown,
   259  				},
   260  			},
   261  			wantAnyErr: false,
   262  		},
   263  	}
   264  
   265  	for _, tt := range testCases {
   266  		t.Run(tt.name, func(t *testing.T) {
   267  			packages := []*extractor.Package{copier.Copy(tt.inputPackage).(*extractor.Package)}
   268  			inv := &inventory.Inventory{Packages: packages}
   269  
   270  			root := setupNPMLockfiles(t, tt.lockfiles)
   271  			input := &annotator.ScanInput{
   272  				ScanRoot: scalibrfs.RealFSScanRoot(root),
   273  			}
   274  
   275  			err := npmsource.New().Annotate(t.Context(), input, inv)
   276  			gotErr := err != nil
   277  			if gotErr != tt.wantAnyErr {
   278  				t.Errorf("Annotate_LockfileV1(%v) error: %v; want error presence = %v", tt.inputPackage, err, tt.wantAnyErr)
   279  			}
   280  
   281  			want := &inventory.Inventory{Packages: []*extractor.Package{tt.wantPackage}}
   282  			if diff := cmp.Diff(want, inv, protocmp.Transform()); diff != "" {
   283  				t.Errorf("Annotate_LockfileV1(%v): unexpected diff (-want +got):\n%s", tt.inputPackage, diff)
   284  			}
   285  		})
   286  	}
   287  }
   288  
   289  func TestAnnotate_LockfileV2(t *testing.T) {
   290  	copier := cpy.New(
   291  		cpy.Func(proto.Clone),
   292  		cpy.IgnoreAllUnexported(),
   293  	)
   294  
   295  	testCases := []struct {
   296  		name         string
   297  		lockfiles    map[string]string
   298  		inputPackage *extractor.Package
   299  		wantPackage  *extractor.Package
   300  		wantAnyErr   bool
   301  	}{
   302  		{
   303  			name: "unfound_package_in_lockfile",
   304  			lockfiles: map[string]string{
   305  				"testproject/package-lock.json": "testdata/package-lock.json",
   306  			},
   307  			inputPackage: &extractor.Package{
   308  				Name:      "abandoned-package",
   309  				PURLType:  "npm",
   310  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   311  			},
   312  			wantPackage: &extractor.Package{
   313  				Name:      "abandoned-package",
   314  				PURLType:  "npm",
   315  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   316  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   317  					Source: metadata.Unknown,
   318  				},
   319  			},
   320  		},
   321  		{
   322  			name: "dependency_from_private_registry",
   323  			lockfiles: map[string]string{
   324  				"testproject/package-lock.json": "testdata/package-lock.json",
   325  			},
   326  			inputPackage: &extractor.Package{
   327  				Name:      "supports-color",
   328  				PURLType:  "npm",
   329  				Locations: []string{"testproject/node_modules/supports-color/package.json"},
   330  			},
   331  			wantPackage: &extractor.Package{
   332  				Name:      "supports-color",
   333  				PURLType:  "npm",
   334  				Locations: []string{"testproject/node_modules/supports-color/package.json"},
   335  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   336  					Source: metadata.Other,
   337  				},
   338  			},
   339  		},
   340  		{
   341  			name: "local_package",
   342  			lockfiles: map[string]string{
   343  				"testproject/package-lock.json": "testdata/package-lock.json",
   344  			},
   345  			inputPackage: &extractor.Package{
   346  				Name:      "local-package",
   347  				PURLType:  "npm",
   348  				Locations: []string{"testproject/node_modules/local-package/package.json"},
   349  			},
   350  			wantPackage: &extractor.Package{
   351  				Name:      "local-package",
   352  				PURLType:  "npm",
   353  				Locations: []string{"testproject/node_modules/local-package/package.json"},
   354  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   355  					Source: metadata.Local,
   356  				},
   357  			},
   358  		},
   359  		{
   360  			name: "scoped_packages_from_npm_repository",
   361  			lockfiles: map[string]string{
   362  				"testproject/package-lock.json": "testdata/package-lock.json",
   363  			},
   364  			inputPackage: &extractor.Package{
   365  				Name:      "@babel/code-frame",
   366  				PURLType:  "npm",
   367  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   368  			},
   369  			wantPackage: &extractor.Package{
   370  				Name:      "@babel/code-frame",
   371  				PURLType:  "npm",
   372  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   373  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   374  					Source: metadata.PublicRegistry,
   375  				},
   376  			},
   377  		},
   378  		{
   379  			name: "alias_package",
   380  			lockfiles: map[string]string{
   381  				"testproject/package-lock.json": "testdata/package-lock.json",
   382  			},
   383  			inputPackage: &extractor.Package{
   384  				Name:      "string-width",
   385  				PURLType:  "npm",
   386  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   387  			},
   388  			wantPackage: &extractor.Package{
   389  				Name:      "string-width",
   390  				PURLType:  "npm",
   391  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   392  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   393  					Source: metadata.PublicRegistry,
   394  				},
   395  			},
   396  		},
   397  		{
   398  			name: "custom_package_from_github_(private_registry)",
   399  			lockfiles: map[string]string{
   400  				"testproject/package-lock.json": "testdata/package-lock.json",
   401  			},
   402  			inputPackage: &extractor.Package{
   403  				Name:      "custom-package",
   404  				PURLType:  "npm",
   405  				Locations: []string{"testproject/node_modules/custom-package/package.json"},
   406  			},
   407  			wantPackage: &extractor.Package{
   408  				Name:      "custom-package",
   409  				PURLType:  "npm",
   410  				Locations: []string{"testproject/node_modules/custom-package/package.json"},
   411  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   412  					Source: metadata.Other,
   413  				},
   414  			},
   415  		},
   416  		{
   417  			name: "nested_packages",
   418  			lockfiles: map[string]string{
   419  				"testproject/package-lock.json": "testdata/package-lock.json",
   420  			},
   421  			inputPackage: &extractor.Package{
   422  				Name:      "wrappy",
   423  				PURLType:  "npm",
   424  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   425  			},
   426  			wantPackage: &extractor.Package{
   427  				Name:      "wrappy",
   428  				PURLType:  "npm",
   429  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   430  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   431  					Source: metadata.PublicRegistry,
   432  				},
   433  			},
   434  		},
   435  		{
   436  			name: "duplicated_packages",
   437  			lockfiles: map[string]string{
   438  				"testproject/package-lock.json": "testdata/package-lock.json",
   439  			},
   440  			inputPackage: &extractor.Package{
   441  				Name:      "@babel/highlight",
   442  				PURLType:  "npm",
   443  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   444  			},
   445  			wantPackage: &extractor.Package{
   446  				Name:      "@babel/highlight",
   447  				PURLType:  "npm",
   448  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   449  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   450  					Source: metadata.PublicRegistry,
   451  				},
   452  			},
   453  		},
   454  		{
   455  			name: "same_package_different_group",
   456  			lockfiles: map[string]string{
   457  				"testproject/package-lock.json": "testdata/package-lock.json",
   458  			},
   459  			inputPackage: &extractor.Package{
   460  				Name:      "ajv",
   461  				PURLType:  "npm",
   462  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   463  			},
   464  			wantPackage: &extractor.Package{
   465  				Name:      "ajv",
   466  				PURLType:  "npm",
   467  				Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   468  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   469  					Source: metadata.PublicRegistry,
   470  				},
   471  			},
   472  		},
   473  		{
   474  			name:      "no lockfile present",
   475  			lockfiles: map[string]string{},
   476  			inputPackage: &extractor.Package{
   477  				Name:      "abandoned-package",
   478  				PURLType:  "npm",
   479  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   480  			},
   481  			wantPackage: &extractor.Package{
   482  				Name:      "abandoned-package",
   483  				PURLType:  "npm",
   484  				Locations: []string{"testproject/node_modules/abandoned-package/package.json"},
   485  				Metadata: &metadata.JavascriptPackageJSONMetadata{
   486  					Source: metadata.Unknown,
   487  				},
   488  			},
   489  			wantAnyErr: false,
   490  		},
   491  	}
   492  
   493  	for _, tt := range testCases {
   494  		t.Run(tt.name, func(t *testing.T) {
   495  			packages := []*extractor.Package{copier.Copy(tt.inputPackage).(*extractor.Package)}
   496  			inv := &inventory.Inventory{Packages: packages}
   497  
   498  			root := setupNPMLockfiles(t, tt.lockfiles)
   499  			input := &annotator.ScanInput{
   500  				ScanRoot: scalibrfs.RealFSScanRoot(root),
   501  			}
   502  
   503  			err := npmsource.New().Annotate(t.Context(), input, inv)
   504  			gotErr := err != nil
   505  			if gotErr != tt.wantAnyErr {
   506  				t.Errorf("Annotate_LockfileV1(%v) error: %v; want error presence = %v", tt.inputPackage, err, tt.wantAnyErr)
   507  			}
   508  
   509  			want := &inventory.Inventory{Packages: []*extractor.Package{tt.wantPackage}}
   510  			if diff := cmp.Diff(want, inv, protocmp.Transform()); diff != "" {
   511  				t.Errorf("Annotate_LockfileV2(%v): unexpected diff (-want +got):\n%s", tt.inputPackage, diff)
   512  			}
   513  		})
   514  	}
   515  }
   516  
   517  func TestMapNPMProjectRootsToPackages(t *testing.T) {
   518  	testCases := []struct {
   519  		name          string
   520  		inputPackages []*extractor.Package
   521  		want          map[string][]*extractor.Package
   522  	}{
   523  		{
   524  			name: "maps_root_directory_to_package_from_node_modules/../package.json",
   525  			inputPackages: []*extractor.Package{
   526  				{
   527  					Name:      "acorn",
   528  					Version:   "1.0.0",
   529  					PURLType:  "npm",
   530  					Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   531  				},
   532  			},
   533  			want: map[string][]*extractor.Package{
   534  				"testproject": []*extractor.Package{
   535  					{
   536  						Name:      "acorn",
   537  						Version:   "1.0.0",
   538  						PURLType:  "npm",
   539  						Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   540  					},
   541  				},
   542  			},
   543  		},
   544  		{
   545  			name: "maps_root_directory_to_package_from_node_modules/../package.json",
   546  			inputPackages: []*extractor.Package{
   547  				{
   548  					Name:      "acorn",
   549  					Version:   "1.0.0",
   550  					PURLType:  "npm",
   551  					Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   552  				},
   553  			},
   554  			want: map[string][]*extractor.Package{
   555  				"testproject": []*extractor.Package{
   556  					{
   557  						Name:      "acorn",
   558  						Version:   "1.0.0",
   559  						PURLType:  "npm",
   560  						Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   561  					},
   562  				},
   563  			},
   564  		},
   565  		{
   566  			name: "no_map_for_non-npm_packages",
   567  			inputPackages: []*extractor.Package{
   568  				{
   569  					Name:      "acorn",
   570  					Version:   "1.0.0",
   571  					PURLType:  "pypi",
   572  					Locations: []string{"testproject/node_modules/dependency-1/package.json"},
   573  				},
   574  			},
   575  			want: make(map[string][]*extractor.Package),
   576  		},
   577  		{
   578  			name: "no_map_for_non-package.json",
   579  			inputPackages: []*extractor.Package{
   580  				{
   581  					Name:      "acorn",
   582  					Version:   "1.0.0",
   583  					PURLType:  "npm",
   584  					Locations: []string{"testproject/node_modules/dependency-2/package2.json"},
   585  				},
   586  			},
   587  			want: make(map[string][]*extractor.Package),
   588  		},
   589  		{
   590  			name: "no_map_for_non-node_modules_directory",
   591  			inputPackages: []*extractor.Package{
   592  				{
   593  					Name:      "acorn",
   594  					Version:   "1.0.0",
   595  					PURLType:  "npm",
   596  					Locations: []string{"testproject/package.json"},
   597  				},
   598  			},
   599  			want: make(map[string][]*extractor.Package),
   600  		},
   601  		{
   602  			name: "no_map_for_empty_locations",
   603  			inputPackages: []*extractor.Package{
   604  				{
   605  					Name:      "acorn",
   606  					Version:   "1.0.0",
   607  					PURLType:  "npm",
   608  					Locations: []string{""},
   609  				},
   610  			},
   611  			want: make(map[string][]*extractor.Package),
   612  		},
   613  	}
   614  
   615  	for _, tt := range testCases {
   616  		t.Run(tt.name, func(t *testing.T) {
   617  			got := npmsource.MapNPMProjectRootsToPackages(tt.inputPackages)
   618  			if diff := cmp.Diff(tt.want, got); diff != "" {
   619  				t.Errorf("MapNPMProjectRootsToPackages(%v): unexpected diff (-want +got): %v", tt.inputPackages, diff)
   620  			}
   621  		})
   622  	}
   623  }
   624  
   625  func TestResolvedFromLockfile(t *testing.T) {
   626  	testCases := []struct {
   627  		name        string
   628  		lockfiles   map[string]string
   629  		wantDeps    map[string]metadata.NPMPackageSource
   630  		wantAnyErr  bool
   631  		skipWindows bool
   632  	}{
   633  		// All 3 lockfiles have the same file structure.
   634  		{
   635  			name: "parse_package-lock.json",
   636  			lockfiles: map[string]string{
   637  				"testproject/package-lock.json": "testdata/package-lock.json",
   638  			},
   639  			wantDeps: map[string]metadata.NPMPackageSource{
   640  				"acorn":             metadata.PublicRegistry,
   641  				"wrappy":            metadata.PublicRegistry,
   642  				"custom-package":    metadata.Other,
   643  				"supports-color":    metadata.Other,
   644  				"ajv":               metadata.PublicRegistry,
   645  				"@babel/highlight":  metadata.PublicRegistry,
   646  				"@babel/code-frame": metadata.PublicRegistry,
   647  				"string-width":      metadata.PublicRegistry,
   648  				"@parcel/watcher":   metadata.Unknown,
   649  				"local-package":     metadata.Local,
   650  			},
   651  			skipWindows: true,
   652  		},
   653  		{
   654  			name: "parse_npm-shrinkwrap.json",
   655  			lockfiles: map[string]string{
   656  				"testproject/npm-shrinkwrap.json": "testdata/package-lock.json",
   657  			},
   658  			wantDeps: map[string]metadata.NPMPackageSource{
   659  				"acorn":             metadata.PublicRegistry,
   660  				"wrappy":            metadata.PublicRegistry,
   661  				"custom-package":    metadata.Other,
   662  				"supports-color":    metadata.Other,
   663  				"ajv":               metadata.PublicRegistry,
   664  				"@babel/highlight":  metadata.PublicRegistry,
   665  				"@babel/code-frame": metadata.PublicRegistry,
   666  				"string-width":      metadata.PublicRegistry,
   667  				"@parcel/watcher":   metadata.Unknown,
   668  				"local-package":     metadata.Local,
   669  			},
   670  			skipWindows: true,
   671  		},
   672  		{
   673  			name: "parse_hidden_package-lock.json_in_/node_modules",
   674  			lockfiles: map[string]string{
   675  				"testproject/node_modules/.package-lock.json": "testdata/package-lock.json",
   676  			},
   677  			wantDeps: map[string]metadata.NPMPackageSource{
   678  				"acorn":             metadata.PublicRegistry,
   679  				"wrappy":            metadata.PublicRegistry,
   680  				"custom-package":    metadata.Other,
   681  				"supports-color":    metadata.Other,
   682  				"ajv":               metadata.PublicRegistry,
   683  				"@babel/highlight":  metadata.PublicRegistry,
   684  				"@babel/code-frame": metadata.PublicRegistry,
   685  				"string-width":      metadata.PublicRegistry,
   686  				"@parcel/watcher":   metadata.Unknown,
   687  				"local-package":     metadata.Local,
   688  			},
   689  			skipWindows: true,
   690  		},
   691  		{
   692  			name:        "parse with no lockfiles returns nothing",
   693  			lockfiles:   map[string]string{},
   694  			wantDeps:    nil,
   695  			wantAnyErr:  false,
   696  			skipWindows: false,
   697  		},
   698  		{
   699  			name: "parse_empty_lockfiles_returns_error",
   700  			lockfiles: map[string]string{
   701  				"testproject/node_modules/.package-lock.json": "empty-file.json",
   702  			},
   703  			wantDeps:    nil,
   704  			wantAnyErr:  true,
   705  			skipWindows: true,
   706  		},
   707  		{
   708  			name: "parse_lockfiles_without_dependencies_and_packages_returns_nothing",
   709  			lockfiles: map[string]string{
   710  				"testproject/node_modules/.package-lock.json": "testdata/no-dep-list-package-lock.json",
   711  			},
   712  			wantDeps:    map[string]metadata.NPMPackageSource{},
   713  			wantAnyErr:  false,
   714  			skipWindows: true,
   715  		},
   716  	}
   717  
   718  	for _, tt := range testCases {
   719  		t.Run(tt.name, func(t *testing.T) {
   720  			root := setupNPMLockfiles(t, tt.lockfiles)
   721  			fsys := scalibrfs.DirFS(root)
   722  
   723  			got, err := npmsource.ResolvedFromLockfile("testproject", fsys)
   724  			gotErr := err != nil
   725  			if gotErr != tt.wantAnyErr {
   726  				t.Errorf("ResolvedFromLockfile(testproject) error: %v; want error presence = %v", err, tt.wantAnyErr)
   727  			}
   728  			if diff := cmp.Diff(tt.wantDeps, got); diff != "" {
   729  				t.Errorf("ResolvedFromLockfile(testproject): unexpected diff (-want +got): %v", diff)
   730  			}
   731  		})
   732  	}
   733  }
   734  
   735  func setupNPMLockfiles(t *testing.T, dbPaths map[string]string) string {
   736  	t.Helper()
   737  	root := t.TempDir()
   738  	for dbPath, contentFile := range dbPaths {
   739  		dbDir := filepath.Join(root, filepath.Dir(dbPath))
   740  		if err := os.MkdirAll(dbDir, 0777); err != nil {
   741  			t.Fatalf("Error creating directory %q: %v", dbDir, err)
   742  		}
   743  
   744  		if contentFile != "empty-file.json" {
   745  			content, err := os.ReadFile(contentFile)
   746  			if err != nil {
   747  				t.Fatalf("Error reading content file %q: %v", contentFile, err)
   748  			}
   749  			writeFile(t, filepath.Join(root, dbPath), content)
   750  		} else {
   751  			writeFile(t, filepath.Join(root, dbPath), []byte{})
   752  		}
   753  	}
   754  	return root
   755  }
   756  
   757  func writeFile(t *testing.T, path string, content []byte) {
   758  	t.Helper()
   759  	if err := os.WriteFile(path, content, 0644); err != nil {
   760  		t.Fatalf("Error creating file %q: %v", path, err)
   761  	}
   762  }