1const t = require('tap')
2const Ajv = require('ajv')
3const applyFormats = require('ajv-formats')
4const applyDraftFormats = require('ajv-formats-draft2019')
5const { cyclonedxOutput } = require('../../../lib/utils/sbom-cyclonedx.js')
6
7const FAKE_UUID = 'urn:uuid:00000000-0000-0000-0000-000000000000'
8
9t.cleanSnapshot = s => {
10  let sbom
11  try {
12    sbom = JSON.parse(s)
13  } catch (e) {
14    return s
15  }
16
17  sbom.serialNumber = FAKE_UUID
18  if (sbom.metadata) {
19    sbom.metadata.timestamp = '2020-01-01T00:00:00.000Z'
20  }
21
22  return JSON.stringify(sbom, null, 2)
23}
24
25const npm = { version: '10.0.0 ' }
26
27const rootPkg = {
28  author: 'Author',
29}
30
31const root = {
32  name: 'root',
33  packageName: 'root',
34  version: '1.0.0',
35  pkgid: 'root@1.0.0',
36  isRoot: true,
37  package: rootPkg,
38  location: '',
39  edgesOut: [],
40}
41
42const dep1 = {
43  name: 'dep1',
44  packageName: 'dep1',
45  version: '0.0.1',
46  pkgid: 'dep1@0.0.1',
47  package: {},
48  location: 'node_modules/dep1',
49  edgesOut: [],
50}
51
52const dep2 = {
53  name: 'dep2',
54  packageName: 'dep2',
55  version: '0.0.2',
56  pkgid: 'npm@npm:dep2@0.0.2',
57  package: {},
58  location: 'node_modules/dep2',
59  edgesOut: [{ to: dep1 }],
60}
61
62const dep2Link = {
63  name: 'dep2',
64  packageName: 'dep2',
65  version: '0.0.2',
66  pkgid: 'dep2@0.0.2',
67  package: {},
68  location: 'node_modules/dep2',
69  edgesOut: [],
70  isLink: true,
71  target: dep2,
72}
73
74t.test('single node - application package type', t => {
75  const res = cyclonedxOutput({ npm, nodes: [root], packageType: 'application' })
76  t.matchSnapshot(JSON.stringify(res))
77  t.end()
78})
79
80t.test('single node - package lock only', t => {
81  const res = cyclonedxOutput({ npm, nodes: [root], packageLockOnly: true })
82  t.matchSnapshot(JSON.stringify(res))
83  t.end()
84})
85
86t.test('single node - optional ', t => {
87  const node = { ...root, optional: true }
88  const res = cyclonedxOutput({ npm, nodes: [node] })
89  t.matchSnapshot(JSON.stringify(res))
90  t.end()
91})
92
93t.test('single node - with description', t => {
94  const pkg = { ...rootPkg, description: 'Package description' }
95  const node = { ...root, package: pkg }
96  const res = cyclonedxOutput({ npm, nodes: [node] })
97  t.matchSnapshot(JSON.stringify(res))
98  t.end()
99})
100
101t.test('single node - with author object', t => {
102  const pkg = { ...rootPkg, author: { name: 'Arthur' } }
103  const node = { ...root, package: pkg }
104  const res = cyclonedxOutput({ npm, nodes: [node] })
105  t.matchSnapshot(JSON.stringify(res))
106  t.end()
107})
108
109t.test('single node - with integrity', t => {
110  /* eslint-disable-next-line max-len */
111  const node = { ...root, integrity: 'sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==' }
112  const res = cyclonedxOutput({ npm, nodes: [node] })
113  t.matchSnapshot(JSON.stringify(res))
114  t.end()
115})
116
117t.test('single node - development', t => {
118  const node = { ...root, dev: true }
119  const res = cyclonedxOutput({ npm, nodes: [node] })
120  t.matchSnapshot(JSON.stringify(res))
121  t.end()
122})
123
124t.test('single node - extraneous', t => {
125  const node = { ...root, extraneous: true }
126  const res = cyclonedxOutput({ npm, nodes: [node] })
127  t.matchSnapshot(JSON.stringify(res))
128  t.end()
129})
130
131t.test('single node - bundled', t => {
132  const node = { ...root, inBundle: true }
133  const res = cyclonedxOutput({ npm, nodes: [node] })
134  t.matchSnapshot(JSON.stringify(res))
135  t.end()
136})
137
138t.test('single node - private', t => {
139  const pkg = { ...rootPkg, private: true }
140  const node = { ...root, package: pkg }
141  const res = cyclonedxOutput({ npm, nodes: [node] })
142  t.matchSnapshot(JSON.stringify(res))
143  t.end()
144})
145
146t.test('single node - with repository url', t => {
147  const pkg = { ...rootPkg, repository: { url: 'https://foo.bar' } }
148  const node = { ...root, package: pkg }
149  const res = cyclonedxOutput({ npm, nodes: [node] })
150  t.matchSnapshot(JSON.stringify(res))
151  t.end()
152})
153
154t.test('single node - with homepage', t => {
155  const pkg = { ...rootPkg, homepage: 'https://foo.bar/README.md' }
156  const node = { ...root, package: pkg }
157  const res = cyclonedxOutput({ npm, nodes: [node] })
158  t.matchSnapshot(JSON.stringify(res))
159  t.end()
160})
161
162t.test('single node - with issue tracker', t => {
163  const pkg = { ...rootPkg, bugs: { url: 'https://foo.bar/issues' } }
164  const node = { ...root, package: pkg }
165  const res = cyclonedxOutput({ npm, nodes: [node] })
166  t.matchSnapshot(JSON.stringify(res))
167  t.end()
168})
169
170t.test('single node - with distribution url', t => {
171  const node = { ...root, resolved: 'https://registry.npmjs.org/root/-/root-1.0.0.tgz' }
172  const res = cyclonedxOutput({ npm, nodes: [node] })
173  t.matchSnapshot(JSON.stringify(res))
174  t.end()
175})
176
177t.test('single node - with single license', t => {
178  const pkg = { ...rootPkg, license: 'ISC' }
179  const node = { ...root, package: pkg }
180  const res = cyclonedxOutput({ npm, nodes: [node] })
181  t.matchSnapshot(JSON.stringify(res))
182  t.end()
183})
184
185t.test('single node - with license expression', t => {
186  const pkg = { ...rootPkg, license: '(MIT OR Apache-2.0)' }
187  const node = { ...root, package: pkg }
188  const res = cyclonedxOutput({ npm, nodes: [node] })
189  t.matchSnapshot(JSON.stringify(res))
190  t.end()
191})
192
193t.test('single node - with license object', t => {
194  const pkg = {
195    ...rootPkg,
196    license: {
197      type: 'MIT',
198      url: 'http://github.com/kriskowal/q/raw/master/LICENSE',
199    },
200  }
201  const node = { ...root, package: pkg }
202  const res = cyclonedxOutput({ npm, nodes: [node] })
203  t.matchSnapshot(JSON.stringify(res))
204  t.end()
205})
206
207t.test('single node - from git url', t => {
208  const node = { ...root, type: 'git', resolved: 'https://github.com/foo/bar#1234' }
209  const res = cyclonedxOutput({ npm, nodes: [node] })
210  t.matchSnapshot(JSON.stringify(res))
211  t.end()
212})
213
214t.test('single node - no package info', t => {
215  const node = { ...root, package: undefined }
216  const res = cyclonedxOutput({ npm, nodes: [node] })
217  t.matchSnapshot(JSON.stringify(res))
218  t.end()
219})
220
221t.test('node - with deps', t => {
222  const node = {
223    ...root,
224    edgesOut: [
225      { to: dep1 },
226      { to: dep2 },
227      { to: undefined },
228      { to: { pkgid: 'foo' } },
229    ],
230  }
231  const res = cyclonedxOutput({ npm, nodes: [node, dep1, dep2, dep2Link] })
232  t.matchSnapshot(JSON.stringify(res))
233  t.end()
234})
235
236// Check that all of the generated test snapshots validate against the CycloneDX schema
237t.test('schema validation', t => {
238  // Load schemas
239  const cdxSchema = require('../../schemas/cyclonedx/bom-1.5.schema.json')
240  const spdxLicenseSchema = require('../../schemas/cyclonedx/spdx.schema.json')
241  const jsfSchema = require('../../schemas/cyclonedx/jsf-0.82.schema.json')
242
243  const ajv = new Ajv({
244    strict: false,
245    schemas: [spdxLicenseSchema, jsfSchema, cdxSchema],
246  })
247  applyFormats(ajv)
248  applyDraftFormats(ajv)
249
250  // Retrieve compiled schema
251  const validate = ajv.getSchema('http://cyclonedx.org/schema/bom-1.5.schema.json')
252
253  // Load snapshots for all tests in this file
254  const sboms = require('../../../tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs')
255
256  // Check that all snapshots validate against the CycloneDX schema
257  Object.entries(sboms).forEach(([name, sbom]) => {
258    t.ok(validate(JSON.parse(sbom)), { snapshot: name, error: validate.errors?.[0] })
259  })
260  t.end()
261})
262