xref: /third_party/node/deps/npm/test/lib/commands/sbom.js (revision 1cb0ef41)
1const t = require('tap')
2const mockNpm = require('../../fixtures/mock-npm.js')
3
4const FAKE_TIMESTAMP = '2020-01-01T00:00:00.000Z'
5const FAKE_UUID = '00000000-0000-0000-0000-000000000000'
6
7t.cleanSnapshot = s => {
8  let sbom
9
10  try {
11    sbom = JSON.parse(s)
12  } catch (e) {
13    return s
14  }
15
16  // Clean dynamic values from snapshots. SPDX and CycloneDX have different
17  // formats for these values, so we need to do it separately.
18  if (sbom.SPDXID) {
19    sbom.documentNamespace = `http://spdx.org/spdxdocs/test-npm-sbom-1.0.0-${FAKE_UUID}`
20
21    if (sbom.creationInfo) {
22      sbom.creationInfo.created = FAKE_TIMESTAMP
23      sbom.creationInfo.creators = ['Tool: npm/cli-10.0.0']
24    }
25  } else {
26    sbom.serialNumber = `urn:uuid:${FAKE_UUID}`
27
28    if (sbom.metadata) {
29      sbom.metadata.timestamp = FAKE_TIMESTAMP
30      sbom.metadata.tools[0].version = '10.0.0'
31    }
32  }
33
34  return JSON.stringify(sbom, null, 2)
35}
36
37const simpleNmFixture = {
38  node_modules: {
39    foo: {
40      'package.json': JSON.stringify({
41        name: 'foo',
42        version: '1.0.0',
43        dependencies: {
44          dog: '^1.0.0',
45        },
46      }),
47      node_modules: {
48        dog: {
49          'package.json': JSON.stringify({
50            name: 'dog',
51            version: '1.0.0',
52          }),
53        },
54      },
55    },
56    chai: {
57      'package.json': JSON.stringify({
58        name: 'chai',
59        version: '1.0.0',
60      }),
61    },
62  },
63}
64
65const mockSbom = async (t, { mocks, config, ...opts } = {}) => {
66  const mock = await mockNpm(t, {
67    ...opts,
68    config: {
69      ...config,
70    },
71    command: 'sbom',
72    mocks: {
73      path: {
74        ...require('path'),
75        sep: '/',
76      },
77      ...mocks,
78    },
79  })
80
81  return {
82    ...mock,
83    result: () => mock.joinedOutput(),
84  }
85}
86
87t.test('sbom', async t => {
88  t.test('basic sbom - spdx', async t => {
89    const config = {
90      'sbom-format': 'spdx',
91    }
92    const { result, sbom } = await mockSbom(t, {
93      config,
94      prefixDir: {
95        'package.json': JSON.stringify({
96          name: 'test-npm-sbom',
97          version: '1.0.0',
98          dependencies: {
99            foo: '^1.0.0',
100            chai: '^1.0.0',
101          },
102        }),
103        ...simpleNmFixture,
104      },
105    })
106    await sbom.exec([])
107    t.matchSnapshot(result())
108  })
109
110  t.test('basic sbom - cyclonedx', async t => {
111    const config = {
112      'sbom-format': 'cyclonedx',
113      'sbom-type': 'application',
114    }
115    const { result, sbom } = await mockSbom(t, {
116      config,
117      prefixDir: {
118        'package.json': JSON.stringify({
119          name: 'test-npm-sbom',
120          version: '1.0.0',
121          dependencies: {
122            foo: '^1.0.0',
123            chai: '^1.0.0',
124          },
125        }),
126        ...simpleNmFixture,
127      },
128    })
129    await sbom.exec([])
130    t.matchSnapshot(result())
131  })
132
133  t.test('--omit dev', async t => {
134    const config = {
135      'sbom-format': 'spdx',
136      omit: ['dev'],
137    }
138    const { result, sbom } = await mockSbom(t, {
139      config,
140      prefixDir: {
141        'package.json': JSON.stringify({
142          name: 'test-npm-sbom',
143          version: '1.0.0',
144          dependencies: {
145            foo: '^1.0.0',
146          },
147          devDependencies: {
148            chai: '^1.0.0',
149          },
150        }),
151        ...simpleNmFixture,
152      },
153    })
154    await sbom.exec([])
155    t.matchSnapshot(result())
156  })
157
158  t.test('--omit optional', async t => {
159    const config = {
160      'sbom-format': 'spdx',
161      omit: ['optional'],
162    }
163    const { result, sbom } = await mockSbom(t, {
164      config,
165      prefixDir: {
166        'package.json': JSON.stringify({
167          name: 'test-npm-sbom',
168          version: '1.0.0',
169          dependencies: {
170            chai: '^1.0.0',
171          },
172          optionalDependencies: {
173            foo: '^1.0.0',
174          },
175        }),
176        ...simpleNmFixture,
177      },
178    })
179    await sbom.exec([])
180    t.matchSnapshot(result())
181  })
182
183  t.test('--omit peer', async t => {
184    const config = {
185      'sbom-format': 'spdx',
186      omit: ['peer'],
187    }
188    const { result, sbom } = await mockSbom(t, {
189      config,
190      prefixDir: {
191        'package.json': JSON.stringify({
192          name: 'test-npm-sbom',
193          version: '1.0.0',
194          dependencies: {
195            chai: '^1.0.0',
196          },
197          peerDependencies: {
198            foo: '^1.0.0',
199          },
200        }),
201        ...simpleNmFixture,
202      },
203    })
204    await sbom.exec([])
205    t.matchSnapshot(result())
206  })
207
208  t.test('missing format', async t => {
209    const config = {}
210    const { result, sbom } = await mockSbom(t, {
211      config,
212      prefixDir: {
213        'package.json': JSON.stringify({
214          name: 'test-npm-sbom',
215          version: '1.0.0',
216          dependencies: {
217            foo: '^1.0.0',
218            chai: '^1.0.0',
219          },
220        }),
221        ...simpleNmFixture,
222      },
223    })
224    await t.rejects(sbom.exec([]), {
225      code: 'EUSAGE',
226      message: 'Must specify --sbom-format flag with one of: cyclonedx, spdx.',
227    },
228    'should throw error')
229
230    t.matchSnapshot(result())
231  })
232
233  t.test('invalid dep', async t => {
234    const config = {
235      'sbom-format': 'spdx',
236    }
237    const { sbom } = await mockSbom(t, {
238      config,
239      prefixDir: {
240        'package.json': JSON.stringify({
241          name: 'test-npm-ls',
242          version: '1.0.0',
243          dependencies: {
244            foo: '^2.0.0',
245          },
246        }),
247        ...simpleNmFixture,
248      },
249    })
250    await t.rejects(
251      sbom.exec([]),
252      { code: 'ESBOMPROBLEMS', message: /invalid: foo@1.0.0/ },
253      'should list dep problems'
254    )
255  })
256
257  t.test('missing dep', async t => {
258    const config = {
259      'sbom-format': 'spdx',
260    }
261    const { sbom } = await mockSbom(t, {
262      config,
263      prefixDir: {
264        'package.json': JSON.stringify({
265          name: 'test-npm-ls',
266          version: '1.0.0',
267          dependencies: {
268            ipsum: '^1.0.0',
269          },
270        }),
271        ...simpleNmFixture,
272      },
273    })
274    await t.rejects(
275      sbom.exec([]),
276      { code: 'ESBOMPROBLEMS', message: /missing: ipsum@\^1.0.0/ },
277      'should list dep problems'
278    )
279  })
280
281  t.test('missing (optional) dep', async t => {
282    const config = {
283      'sbom-format': 'spdx',
284    }
285    const { result, sbom } = await mockSbom(t, {
286      config,
287      prefixDir: {
288        'package.json': JSON.stringify({
289          name: 'test-npm-ls',
290          version: '1.0.0',
291          dependencies: {
292            foo: '^1.0.0',
293            chai: '^1.0.0',
294          },
295          optionalDependencies: {
296            ipsum: '^1.0.0',
297          },
298        }),
299        ...simpleNmFixture,
300      },
301    })
302    await sbom.exec([])
303    t.matchSnapshot(result())
304  })
305
306  t.test('extraneous dep', async t => {
307    const config = {
308      'sbom-format': 'spdx',
309    }
310    const { result, sbom } = await mockSbom(t, {
311      config,
312      prefixDir: {
313        'package.json': JSON.stringify({
314          name: 'test-npm-ls',
315          version: '1.0.0',
316          dependencies: {
317            foo: '^1.0.0',
318          },
319        }),
320        ...simpleNmFixture,
321      },
322    })
323    await sbom.exec([])
324    t.matchSnapshot(result())
325  })
326
327  t.test('lock file only', async t => {
328    const config = {
329      'sbom-format': 'spdx',
330      'package-lock-only': true,
331    }
332    const { result, sbom } = await mockSbom(t, {
333      config,
334      prefixDir: {
335        'package.json': JSON.stringify({
336          name: 'test-npm-ls',
337          version: '1.0.0',
338          dependencies: {
339            foo: '^1.0.0',
340            chai: '^1.0.0',
341          },
342        }),
343        'package-lock.json': JSON.stringify({
344          dependencies: {
345            foo: {
346              version: '1.0.0',
347              requires: {
348                dog: '^1.0.0',
349              },
350            },
351            dog: {
352              version: '1.0.0',
353            },
354            chai: {
355              version: '1.0.0',
356            },
357          },
358        }),
359      },
360    })
361    await sbom.exec([])
362    t.matchSnapshot(result())
363  })
364
365  t.test('lock file only - missing lock file', async t => {
366    const config = {
367      'sbom-format': 'spdx',
368      'package-lock-only': true,
369    }
370    const { result, sbom } = await mockSbom(t, {
371      config,
372      prefixDir: {
373        'package.json': JSON.stringify({
374          name: 'test-npm-ls',
375          version: '1.0.0',
376          dependencies: {
377            foo: '^1.0.0',
378            chai: '^1.0.0',
379          },
380        }),
381      },
382    })
383    await t.rejects(sbom.exec([]), {
384      code: 'EUSAGE',
385      message: 'A package lock or shrinkwrap file is required in package-lock-only mode',
386    },
387    'should throw error')
388
389    t.matchSnapshot(result())
390  })
391
392  t.test('loading a tree containing workspaces', async t => {
393    const mockWorkspaces = async (t, exec = [], config = {}) => {
394      const { result, sbom } = await mockSbom(t, {
395        config,
396        prefixDir: {
397          'package.json': JSON.stringify({
398            name: 'workspaces-tree',
399            version: '1.0.0',
400            workspaces: ['./a', './b', './d', './group/*'],
401            dependencies: { pacote: '1.0.0' },
402          }),
403          node_modules: {
404            a: t.fixture('symlink', '../a'),
405            b: t.fixture('symlink', '../b'),
406            c: {
407              'package.json': JSON.stringify({
408                name: 'c',
409                version: '1.0.0',
410              }),
411            },
412            d: t.fixture('symlink', '../d'),
413            e: t.fixture('symlink', '../group/e'),
414            f: t.fixture('symlink', '../group/f'),
415            foo: {
416              'package.json': JSON.stringify({
417                name: 'foo',
418                version: '1.1.1',
419                dependencies: {
420                  bar: '^1.0.0',
421                },
422              }),
423            },
424            bar: {
425              'package.json': JSON.stringify({ name: 'bar', version: '1.0.0' }),
426            },
427            baz: {
428              'package.json': JSON.stringify({ name: 'baz', version: '1.0.0' }),
429            },
430            pacote: {
431              'package.json': JSON.stringify({ name: 'pacote', version: '1.0.0' }),
432            },
433          },
434          a: {
435            'package.json': JSON.stringify({
436              name: 'a',
437              version: '1.0.0',
438              dependencies: {
439                c: '^1.0.0',
440                d: '^1.0.0',
441              },
442              devDependencies: {
443                baz: '^1.0.0',
444              },
445            }),
446          },
447          b: {
448            'package.json': JSON.stringify({
449              name: 'b',
450              version: '1.0.0',
451            }),
452          },
453          d: {
454            'package.json': JSON.stringify({
455              name: 'd',
456              version: '1.0.0',
457              dependencies: {
458                foo: '^1.1.1',
459              },
460            }),
461          },
462          group: {
463            e: {
464              'package.json': JSON.stringify({
465                name: 'e',
466                version: '1.0.0',
467              }),
468            },
469            f: {
470              'package.json': JSON.stringify({
471                name: 'f',
472                version: '1.0.0',
473              }),
474            },
475          },
476        },
477      })
478
479      await sbom.exec(exec)
480
481      t.matchSnapshot(result())
482    }
483
484    t.test('should list workspaces properly with default configs', t => mockWorkspaces(t, [], {
485      'sbom-format': 'spdx',
486    }))
487
488    t.test('should not list workspaces with --no-workspaces', t => mockWorkspaces(t, [], {
489      'sbom-format': 'spdx',
490      workspaces: false,
491    }))
492
493    t.test('should filter worksapces with --workspace', t => mockWorkspaces(t, [], {
494      'sbom-format': 'spdx',
495      workspace: 'a',
496    }))
497
498    t.test('should filter workspaces with multiple --workspace flags', t => mockWorkspaces(t, [], {
499      'sbom-format': 'spdx',
500      workspace: ['e', 'f'],
501    }))
502  })
503})
504