github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/language/javascript/packagejson/packagejson_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 packagejson_test
    16  
    17  import (
    18  	"io/fs"
    19  	"os"
    20  	"path/filepath"
    21  	"testing"
    22  
    23  	"github.com/google/go-cmp/cmp"
    24  	"github.com/google/go-cmp/cmp/cmpopts"
    25  	"github.com/google/osv-scalibr/extractor"
    26  	"github.com/google/osv-scalibr/extractor/filesystem"
    27  	"github.com/google/osv-scalibr/extractor/filesystem/internal/units"
    28  	"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagejson"
    29  	"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagejson/metadata"
    30  	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
    31  	scalibrfs "github.com/google/osv-scalibr/fs"
    32  	"github.com/google/osv-scalibr/inventory"
    33  	"github.com/google/osv-scalibr/purl"
    34  	"github.com/google/osv-scalibr/stats"
    35  	"github.com/google/osv-scalibr/testing/extracttest"
    36  	"github.com/google/osv-scalibr/testing/fakefs"
    37  	"github.com/google/osv-scalibr/testing/testcollector"
    38  )
    39  
    40  func TestFileRequired(t *testing.T) {
    41  	tests := []struct {
    42  		name             string
    43  		path             string
    44  		fileSizeBytes    int64
    45  		maxFileSizeBytes int64
    46  		wantRequired     bool
    47  		wantResultMetric stats.FileRequiredResult
    48  	}{
    49  		{
    50  			name:             "package.json at root",
    51  			path:             "package.json",
    52  			wantRequired:     true,
    53  			wantResultMetric: stats.FileRequiredResultOK,
    54  		},
    55  		{
    56  			name:             "top level package.json",
    57  			path:             "testdata/package.json",
    58  			wantRequired:     true,
    59  			wantResultMetric: stats.FileRequiredResultOK,
    60  		},
    61  		{
    62  			name:             "tests library",
    63  			path:             "testdata/deps/accepts/package.json",
    64  			wantRequired:     true,
    65  			wantResultMetric: stats.FileRequiredResultOK,
    66  		},
    67  		{
    68  			name:         "not package.json",
    69  			path:         "testdata/test.js",
    70  			wantRequired: false,
    71  		},
    72  		{
    73  			name:             "package.json required if size less than maxFileSizeBytes",
    74  			path:             "package.json",
    75  			fileSizeBytes:    1000 * units.MiB,
    76  			maxFileSizeBytes: 2000 * units.MiB,
    77  			wantRequired:     true,
    78  			wantResultMetric: stats.FileRequiredResultOK,
    79  		},
    80  		{
    81  			name:             "package.json required if size equal to maxFileSizeBytes",
    82  			path:             "package.json",
    83  			fileSizeBytes:    1000 * units.MiB,
    84  			maxFileSizeBytes: 1000 * units.MiB,
    85  			wantRequired:     true,
    86  			wantResultMetric: stats.FileRequiredResultOK,
    87  		},
    88  		{
    89  			name:             "package.json not required if size greater than maxFileSizeBytes",
    90  			path:             "package.json",
    91  			fileSizeBytes:    10000 * units.MiB,
    92  			maxFileSizeBytes: 1000 * units.MiB,
    93  			wantRequired:     false,
    94  			wantResultMetric: stats.FileRequiredResultSizeLimitExceeded,
    95  		},
    96  		{
    97  			name:             "package.json required if maxFileSizeBytes explicitly set to 0",
    98  			path:             "package.json",
    99  			fileSizeBytes:    1000 * units.MiB,
   100  			maxFileSizeBytes: 0,
   101  			wantRequired:     true,
   102  			wantResultMetric: stats.FileRequiredResultOK,
   103  		},
   104  	}
   105  
   106  	for _, tt := range tests {
   107  		// Note the subtest here
   108  		t.Run(tt.name, func(t *testing.T) {
   109  			collector := testcollector.New()
   110  			e := packagejson.New(packagejson.Config{
   111  				Stats:            collector,
   112  				MaxFileSizeBytes: tt.maxFileSizeBytes,
   113  			})
   114  
   115  			// Set a default file size if not specified.
   116  			fileSizeBytes := tt.fileSizeBytes
   117  			if fileSizeBytes == 0 {
   118  				fileSizeBytes = 1 * units.KiB
   119  			}
   120  
   121  			isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{
   122  				FileName: filepath.Base(tt.path),
   123  				FileMode: fs.ModePerm,
   124  				FileSize: fileSizeBytes,
   125  			}))
   126  			if isRequired != tt.wantRequired {
   127  				t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired)
   128  			}
   129  
   130  			gotResultMetric := collector.FileRequiredResult(tt.path)
   131  			if gotResultMetric != tt.wantResultMetric {
   132  				t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric)
   133  			}
   134  		})
   135  	}
   136  }
   137  
   138  func TestExtract(t *testing.T) {
   139  	tests := []struct {
   140  		name             string
   141  		path             string
   142  		cfg              packagejson.Config
   143  		wantPackages     []*extractor.Package
   144  		wantErr          error
   145  		wantResultMetric stats.FileExtractedResult
   146  	}{
   147  		{
   148  			name: "top_level_package.json",
   149  			path: "testdata/package.json",
   150  			wantPackages: []*extractor.Package{
   151  				{
   152  					Name:      "testdata",
   153  					Version:   "10.46.8",
   154  					PURLType:  purl.TypeNPM,
   155  					Locations: []string{"testdata/package.json"},
   156  					Metadata: &metadata.JavascriptPackageJSONMetadata{
   157  						Author: &metadata.Person{
   158  							Name:  "Developer",
   159  							Email: "dev@corp.com",
   160  							URL:   "http://blog.dev.com",
   161  						},
   162  					},
   163  				},
   164  			},
   165  		},
   166  		{
   167  			name: "accepts",
   168  			path: "testdata/deps/accepts/package.json",
   169  			wantPackages: []*extractor.Package{
   170  				{
   171  					Name:      "accepts",
   172  					Version:   "1.3.8",
   173  					PURLType:  purl.TypeNPM,
   174  					Locations: []string{"testdata/deps/accepts/package.json"},
   175  					Metadata: &metadata.JavascriptPackageJSONMetadata{
   176  						Contributors: []*metadata.Person{
   177  							{
   178  								Name:  "Douglas Christopher Wilson",
   179  								Email: "doug@somethingdoug.com",
   180  							},
   181  							{
   182  								Name:  "Jonathan Ong",
   183  								Email: "me@jongleberry.com",
   184  								URL:   "http://jongleberry.com",
   185  							},
   186  						},
   187  					},
   188  				},
   189  			},
   190  		},
   191  		{
   192  			name: "no_person_name",
   193  			path: "testdata/deps/no-person-name/package.json",
   194  			wantPackages: []*extractor.Package{
   195  				{
   196  					Name:      "accepts",
   197  					Version:   "1.3.8",
   198  					PURLType:  purl.TypeNPM,
   199  					Locations: []string{"testdata/deps/no-person-name/package.json"},
   200  					Metadata: &metadata.JavascriptPackageJSONMetadata{
   201  						Contributors: []*metadata.Person{
   202  							{
   203  								Name:  "Jonathan Ong",
   204  								Email: "me@jongleberry.com",
   205  								URL:   "http://jongleberry.com",
   206  							},
   207  						},
   208  					},
   209  				},
   210  			},
   211  		},
   212  		{
   213  			name: "nested_acorn",
   214  			path: "testdata/deps/with/deps/acorn/package.json",
   215  			wantPackages: []*extractor.Package{
   216  				{
   217  					Name:      "acorn",
   218  					Version:   "1.2.2",
   219  					PURLType:  purl.TypeNPM,
   220  					Locations: []string{"testdata/deps/with/deps/acorn/package.json"},
   221  					Metadata: &metadata.JavascriptPackageJSONMetadata{
   222  						Maintainers: []*metadata.Person{
   223  							{
   224  								Name:  "Marijn Haverbeke",
   225  								Email: "marijnh@gmail.com",
   226  							},
   227  							{
   228  								Name:  "Ingvar Stepanyan",
   229  								Email: "me@rreverser.com",
   230  							},
   231  						},
   232  					},
   233  				},
   234  			},
   235  		},
   236  		{
   237  			name:         "empty name",
   238  			path:         "testdata/deps/acorn/package.json",
   239  			wantPackages: []*extractor.Package{},
   240  		},
   241  		{
   242  			name:         "empty version",
   243  			path:         "testdata/deps/acorn-globals/package.json",
   244  			wantPackages: []*extractor.Package{},
   245  		},
   246  		{
   247  			name:         "missing name and version",
   248  			path:         "testdata/deps/window-size/package.json",
   249  			wantPackages: []*extractor.Package{},
   250  		},
   251  		{
   252  			name:         "VSCode extension",
   253  			path:         "testdata/vscode-extension.json",
   254  			wantPackages: []*extractor.Package{},
   255  		},
   256  		{
   257  			name:         "VSCode extension with only required fields",
   258  			path:         "testdata/vscode-extension-only-required.json",
   259  			wantPackages: []*extractor.Package{},
   260  		},
   261  		{
   262  			name:         "Unity package",
   263  			path:         "testdata/unity-package.json",
   264  			wantPackages: []*extractor.Package{},
   265  		},
   266  		{
   267  			name: "Undici_package_with_nonstandard_contributors_parsed_correctly",
   268  			path: "testdata/undici-package.json",
   269  			wantPackages: []*extractor.Package{
   270  				{
   271  					Name:     "undici",
   272  					Version:  "5.28.3",
   273  					PURLType: purl.TypeNPM,
   274  					Locations: []string{
   275  						"testdata/undici-package.json",
   276  					},
   277  					Metadata: &metadata.JavascriptPackageJSONMetadata{
   278  						Contributors: []*metadata.Person{
   279  							{
   280  								Name: "Daniele Belardi",
   281  								URL:  "https://github.com/dnlup",
   282  							},
   283  							{
   284  								Name: "Tomas Della Vedova",
   285  								URL:  "https://github.com/delvedor",
   286  							},
   287  							{
   288  								Name: "Invalid URL NoCrash",
   289  							},
   290  						},
   291  					},
   292  				},
   293  			},
   294  		},
   295  		{
   296  			name: "npm_package_with_engine_field_set",
   297  			path: "testdata/not-vscode.json",
   298  			wantPackages: []*extractor.Package{
   299  				{
   300  					Name:      "jsonparse",
   301  					Version:   "1.3.1",
   302  					PURLType:  purl.TypeNPM,
   303  					Locations: []string{"testdata/not-vscode.json"},
   304  					Metadata: &metadata.JavascriptPackageJSONMetadata{
   305  						Author: &metadata.Person{
   306  							Name:  "Tim Caswell",
   307  							Email: "tim@creationix.com",
   308  						},
   309  					},
   310  				},
   311  			},
   312  		},
   313  		{
   314  			name: "package_with_dependencies",
   315  			path: "testdata/package-with-deps.json",
   316  			cfg:  packagejson.Config{IncludeDependencies: true},
   317  			wantPackages: []*extractor.Package{
   318  				{
   319  					Name:      "package-with-deps",
   320  					Version:   "1.2.3",
   321  					PURLType:  purl.TypeNPM,
   322  					Locations: []string{"testdata/package-with-deps.json"},
   323  					Metadata:  &metadata.JavascriptPackageJSONMetadata{},
   324  				},
   325  				{
   326  					Name:      "dep1",
   327  					Version:   "1.0.0",
   328  					PURLType:  purl.TypeNPM,
   329  					Locations: []string{"testdata/package-with-deps.json"},
   330  				},
   331  				{
   332  					Name:      "dep2",
   333  					Version:   "2.0.1",
   334  					PURLType:  purl.TypeNPM,
   335  					Locations: []string{"testdata/package-with-deps.json"},
   336  				},
   337  				{
   338  					Name:      "dep3",
   339  					Version:   "3.1.0",
   340  					PURLType:  purl.TypeNPM,
   341  					Locations: []string{"testdata/package-with-deps.json"},
   342  				},
   343  				{
   344  					Name:      "dep4",
   345  					Version:   "0.4.2",
   346  					PURLType:  purl.TypeNPM,
   347  					Locations: []string{"testdata/package-with-deps.json"},
   348  				},
   349  				{
   350  					Name:      "dep5",
   351  					Version:   "5.0.0",
   352  					PURLType:  purl.TypeNPM,
   353  					Locations: []string{"testdata/package-with-deps.json"},
   354  				},
   355  				// dep6 is invalid, so it should not be included.
   356  				{
   357  					Name:      "dep7",
   358  					Version:   "1.0.0",
   359  					PURLType:  purl.TypeNPM,
   360  					Locations: []string{"testdata/package-with-deps.json"},
   361  				},
   362  			},
   363  		},
   364  		{
   365  			name:             "invalid packagejson",
   366  			path:             "testdata/invalid",
   367  			wantErr:          cmpopts.AnyError,
   368  			wantResultMetric: stats.FileExtractedResultErrorUnknown,
   369  		},
   370  	}
   371  
   372  	for _, tt := range tests {
   373  		// Note the subtest here
   374  		t.Run(tt.name, func(t *testing.T) {
   375  			r, err := os.Open(tt.path)
   376  			defer func() {
   377  				if err = r.Close(); err != nil {
   378  					t.Errorf("Close(): %v", err)
   379  				}
   380  			}()
   381  			if err != nil {
   382  				t.Fatal(err)
   383  			}
   384  
   385  			info, err := os.Stat(tt.path)
   386  			if err != nil {
   387  				t.Fatal(err)
   388  			}
   389  
   390  			collector := testcollector.New()
   391  			tt.cfg.Stats = collector
   392  
   393  			input := &filesystem.ScanInput{
   394  				FS:     scalibrfs.DirFS("."),
   395  				Path:   tt.path,
   396  				Reader: r,
   397  				Info:   info,
   398  			}
   399  			e := packagejson.New(defaultConfigWith(tt.cfg))
   400  			got, err := e.Extract(t.Context(), input)
   401  			if !cmp.Equal(err, tt.wantErr, cmpopts.EquateErrors()) {
   402  				t.Fatalf("Extract(%+v) error: got %v, want %v\n", tt.name, err, tt.wantErr)
   403  			}
   404  
   405  			var want inventory.Inventory
   406  			if tt.wantPackages != nil {
   407  				want = inventory.Inventory{Packages: tt.wantPackages}
   408  			}
   409  
   410  			if diff := cmp.Diff(want, got, cmpopts.SortSlices(extracttest.PackageCmpLess), cmpopts.EquateEmpty()); diff != "" {
   411  				t.Errorf("Extract(%s) (-want +got):\n%s", tt.path, diff)
   412  			}
   413  
   414  			wantResultMetric := tt.wantResultMetric
   415  			if wantResultMetric == "" && tt.wantErr == nil {
   416  				wantResultMetric = stats.FileExtractedResultSuccess
   417  			}
   418  			gotResultMetric := collector.FileExtractedResult(tt.path)
   419  			if gotResultMetric != wantResultMetric {
   420  				t.Errorf("Extract(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, wantResultMetric)
   421  			}
   422  
   423  			gotFileSizeMetric := collector.FileExtractedFileSize(tt.path)
   424  			if gotFileSizeMetric != info.Size() {
   425  				t.Errorf("Extract(%s) recorded file size %v, want file size %v", tt.path, gotFileSizeMetric, info.Size())
   426  			}
   427  		})
   428  	}
   429  }
   430  
   431  // defaultConfigWith combines any non-zero fields of cfg with packagejson.DefaultConfig().
   432  func defaultConfigWith(cfg packagejson.Config) packagejson.Config {
   433  	newCfg := packagejson.DefaultConfig()
   434  	newCfg.IncludeDependencies = cfg.IncludeDependencies
   435  
   436  	if cfg.Stats != nil {
   437  		newCfg.Stats = cfg.Stats
   438  	}
   439  	if cfg.MaxFileSizeBytes > 0 {
   440  		newCfg.MaxFileSizeBytes = cfg.MaxFileSizeBytes
   441  	}
   442  	return newCfg
   443  }