[READ-ONLY] Mirror of https://github.com/danielroe/provenance-action. Fail CI when dependencies in your lockfile lose npm provenance or trusted publisher status
github-actions
provenance
security
trusted-publishing
23 kB
673 lines
1import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { join } from 'node:path'
4import { describe, expect, it } from 'vitest'
5import {
6 detectLockfile,
7 diffDependencySets,
8 findLockfileLine,
9 matchesTrustPolicyExclude,
10 parseBunLock,
11 parseLockfile,
12 parseNpmLock,
13 parsePnpmLock,
14 parsePnpmWorkspaceConfig,
15 parseTrustPolicyExclude,
16 parseYarnBerryLock,
17 parseYarnV1Lock,
18 readTextFile,
19 yarnBerrySpecifierToName,
20 yarnV1SpecifierToName,
21} from '../lib/lockfile.ts'
22
23describe('lockfile utils', () => {
24 it('detectLockfile finds supported lockfiles', () => {
25 const dir = mkdtempSync(join(tmpdir(), 'oidc-action-'))
26 try {
27 // No file
28 expect(detectLockfile(dir)).toBe(undefined)
29 // Create pnpm first
30 writeFileSync(join(dir, 'pnpm-lock.yaml'), '')
31 expect(detectLockfile(dir)).toBe('pnpm-lock.yaml')
32 // If pnpm exists, it should still prefer that even if others are present
33 writeFileSync(join(dir, 'package-lock.json'), '{}')
34 writeFileSync(join(dir, 'yarn.lock'), '')
35 expect(detectLockfile(dir)).toBe('pnpm-lock.yaml')
36 }
37 finally {
38 rmSync(dir, { recursive: true, force: true })
39 }
40 })
41
42 it('readTextFile reads utf8 content', () => {
43 const dir = mkdtempSync(join(tmpdir(), 'oidc-action-'))
44 try {
45 const p = join(dir, 'file.txt')
46 writeFileSync(p, 'hello', 'utf8')
47 expect(readTextFile(p)).toBe('hello')
48 }
49 finally {
50 rmSync(dir, { recursive: true, force: true })
51 }
52 })
53
54 // Test parseLockfile with unsupported file types
55 it('parseLockfile returns empty map for unsupported lockfile types', () => {
56 const result = parseLockfile('unsupported.lock', 'content')
57 expect(result).toBeInstanceOf(Map)
58 expect(result.size).toBe(0)
59 })
60
61 // Test yarnV1SpecifierToName edge cases
62 it('yarnV1SpecifierToName handles specs without @', () => {
63 expect(yarnV1SpecifierToName('invalid-spec')).toBe(undefined)
64 })
65
66 it('yarnV1SpecifierToName handles specs with @ at position 0', () => {
67 expect(yarnV1SpecifierToName('@invalid')).toBe(undefined)
68 })
69
70 // Test yarnBerrySpecifierToName edge cases
71 it('yarnBerrySpecifierToName handles scoped packages without version', () => {
72 expect(yarnBerrySpecifierToName('@scope/name')).toBe(undefined)
73 })
74
75 it('yarnBerrySpecifierToName handles regular packages without version', () => {
76 expect(yarnBerrySpecifierToName('name')).toBe(undefined)
77 })
78
79 it('yarnBerrySpecifierToName handles quoted specs', () => {
80 expect(yarnBerrySpecifierToName('"@scope/name@npm:1.0.0"')).toBe('@scope/name')
81 expect(yarnBerrySpecifierToName('\'test@npm:1.0.0\'')).toBe('test')
82 })
83
84 // Test findLockfileLine edge cases
85 it('findLockfileLine returns undefined for unsupported lockfile types', () => {
86 const result = findLockfileLine('unsupported.lock', 'content', 'test', '1.0.0')
87 expect(result).toBe(undefined)
88 })
89
90 it('findLockfileLine handles npm lockfile without matches', () => {
91 const content = '{"packages": {}}'
92 const result = findLockfileLine('package-lock.json', content, 'nonexistent', '1.0.0')
93 expect(result).toBe(undefined)
94 })
95
96 it('findLockfileLine handles pnpm lockfile without matches', () => {
97 const content = 'packages:\n\n /other@1.0.0:\n resolution: {}'
98 const result = findLockfileLine('pnpm-lock.yaml', content, 'test', '1.0.0')
99 expect(result).toBe(undefined)
100 })
101
102 it('findLockfileLine handles yarn v1 lockfile without matches', () => {
103 const content = '# yarn lockfile v1\n\nother@^1.0.0:\n version "1.0.0"'
104 const result = findLockfileLine('yarn.lock', content, 'test', '1.0.0')
105 expect(result).toBe(undefined)
106 })
107
108 it('findLockfileLine handles yarn berry lockfile without matches', () => {
109 const content = '"other@npm:^1.0.0":\n version: "1.0.0"'
110 const result = findLockfileLine('yarn.lock', content, 'test', '1.0.0')
111 expect(result).toBe(undefined)
112 })
113
114 it('findLockfileLine handles bun lockfile without matches', () => {
115 const content = '{"packages": {"different@2.0.0": {"version": "2.0.0"}}}'
116 const result = findLockfileLine('bun.lock', content, 'test', '1.0.0')
117 expect(result).toBe(undefined)
118 })
119
120 // Test diffDependencySets to cover setsEqual edge cases
121 it('diffDependencySets detects different versions in sets', () => {
122 const prev = new Map([['lodash', new Set(['4.17.21'])]])
123 const curr = new Map([['lodash', new Set(['4.17.22'])]])
124 const diff = diffDependencySets(prev, curr)
125 expect(diff.length).toBe(1)
126 expect(diff[0].name).toBe('lodash')
127 })
128
129 it('diffDependencySets detects removed packages', () => {
130 const prev = new Map([['lodash', new Set(['4.17.21'])], ['removed', new Set(['1.0.0'])]])
131 const curr = new Map([['lodash', new Set(['4.17.21'])]])
132 const diff = diffDependencySets(prev, curr)
133 expect(diff.length).toBe(1)
134 expect(diff[0].name).toBe('removed')
135 expect(diff[0].previous.has('1.0.0')).toBe(true)
136 expect(diff[0].current.size).toBe(0)
137 })
138})
139
140describe('parseNpmLock', () => {
141// Test parseNpmLock error cases
142 it('parseNpmLock handles malformed JSON', () => {
143 const result = parseNpmLock('invalid json {')
144 expect(result).toBeInstanceOf(Map)
145 expect(result.size).toBe(0)
146 })
147
148 it('parseNpmLock handles missing packages property', () => {
149 const result = parseNpmLock('{}')
150 expect(result).toBeInstanceOf(Map)
151 expect(result.size).toBe(0)
152 })
153
154 it('parseNpmLock handles entries without version', () => {
155 const content = '{"packages":{"node_modules/test":{"name":"test"}}}'
156 const result = parseNpmLock(content)
157 expect(result.size).toBe(0)
158 })
159
160 it('parseNpmLock handles root package entry', () => {
161 const content = '{"packages":{"":{"name":"root","version":"1.0.0"}}}'
162 const result = parseNpmLock(content)
163 expect(result.size).toBe(0) // Root package should be skipped
164 })
165
166 it('parseNpmLock handles entry without name', () => {
167 const content = '{"packages":{"node_modules/test":{"version":"1.0.0"}}}'
168 const result = parseNpmLock(content)
169 expect(result.get('test')).toContain('1.0.0')
170 })
171
172 it('parseNpmLock handles entry with explicit name', () => {
173 const content = '{"packages":{"node_modules/test":{"name":"different","version":"1.0.0"}}}'
174 const result = parseNpmLock(content)
175 expect(result.get('different')).toContain('1.0.0')
176 })
177})
178
179describe('parsePnpmLock', () => {
180// Test parsePnpmLock edge cases
181 it('parsePnpmLock handles content without packages section', () => {
182 const content = 'settings:\n autoInstallPeers: true'
183 const result = parsePnpmLock(content)
184 expect(result.size).toBe(0)
185 })
186
187 it('parsePnpmLock handles packages section without entries', () => {
188 const content = 'packages:\n\nsettings:\n autoInstallPeers: true'
189 const result = parsePnpmLock(content)
190 expect(result.size).toBe(0)
191 })
192
193 it('parsePnpmLock handles malformed package entries', () => {
194 const content = `packages:
195
196 /test@1.0.0:
197 resolution: {integrity: sha512-test}
198
199 invalid-entry-without-colon
200
201 /valid@2.0.0:
202 resolution: {integrity: sha512-test}`
203 const result = parsePnpmLock(content)
204 // Both test and valid should be parsed because both have valid format
205 expect(result.get('test')).toContain('1.0.0')
206 expect(result.get('valid')).toContain('2.0.0')
207 expect(result.size).toBe(2)
208 })
209
210 it('parsePnpmLock handles entries without @ symbol', () => {
211 const content = `packages:
212
213 /no-at-symbol:
214 resolution: {integrity: sha512-test}`
215 const result = parsePnpmLock(content)
216 expect(result.size).toBe(0)
217 })
218
219 it('parsePnpmLock handles entries with @ at position 0', () => {
220 const content = `packages:
221
222 /@scoped:
223 resolution: {integrity: sha512-test}`
224 const result = parsePnpmLock(content)
225 expect(result.size).toBe(0)
226 })
227
228 it('parsePnpmLock handles entries without version', () => {
229 const content = `packages:
230
231 /test@:
232 resolution: {integrity: sha512-test}`
233 const result = parsePnpmLock(content)
234 expect(result.size).toBe(0)
235 })
236
237 it('parsePnpmLock handles quoted package names', () => {
238 const content = `packages:
239
240 "/test@1.0.0":
241 resolution: {integrity: sha512-test}
242
243 '/another@2.0.0':
244 resolution: {integrity: sha512-test}`
245 const result = parsePnpmLock(content)
246 expect(result.size).toBe(2)
247 // The parser doesn't strip leading slash for quoted names
248 expect(result.get('/test')).toContain('1.0.0')
249 expect(result.get('/another')).toContain('2.0.0')
250 })
251
252 it('parsePnpmLock handles packages with peer dependencies suffix', () => {
253 const content = `packages:
254
255 /test@1.0.0(peer@2.0.0):
256 resolution: {integrity: sha512-test}`
257 const result = parsePnpmLock(content)
258 expect(result.get('test')).toContain('1.0.0')
259 })
260})
261
262describe('parseYarnV1Lock', () => {
263// Test parseYarnV1Lock edge cases
264 it('parseYarnV1Lock handles entries without version', () => {
265 const content = `# yarn lockfile v1
266
267test@^1.0.0:
268 resolved "https://registry.yarnpkg.com/test"
269 # no version field`
270 const result = parseYarnV1Lock(content)
271 expect(result.size).toBe(0)
272 })
273
274 it('parseYarnV1Lock handles multi-line headers', () => {
275 const content = `# yarn lockfile v1
276
277"test@^1.0.0",
278"test@^1.1.0":
279 version "1.2.0"
280 resolved "https://registry.yarnpkg.com/test"`
281 const result = parseYarnV1Lock(content)
282 expect(result.get('test')).toContain('1.2.0')
283 })
284
285 it('parseYarnV1Lock skips invalid specifiers', () => {
286 const content = `# yarn lockfile v1
287
288invalid-spec:
289 version "1.0.0"
290
291test@^1.0.0:
292 version "2.0.0"`
293 const result = parseYarnV1Lock(content)
294 expect(result.get('test')).toContain('2.0.0')
295 expect(result.size).toBe(1)
296 })
297})
298
299describe('parseYarnBerryLock', () => {
300// Test parseYarnBerryLock edge cases
301 it('parseYarnBerryLock handles entries without version', () => {
302 const content = `"test@npm:^1.0.0":
303 resolution: "test@npm:1.2.0"
304 # no version field`
305 const result = parseYarnBerryLock(content)
306 expect(result.size).toBe(0)
307 })
308
309 it('parseYarnBerryLock handles invalid specifiers', () => {
310 const content = `"invalid-spec":
311 version: "1.0.0"
312
313"test@npm:^1.0.0":
314 version: "2.0.0"`
315 const result = parseYarnBerryLock(content)
316 expect(result.get('test')).toContain('2.0.0')
317 expect(result.size).toBe(1)
318 })
319
320 it('parseYarnBerryLock skips comment lines', () => {
321 const content = `# This is a comment
322"test@npm:^1.0.0":
323 version: "1.0.0"
324
325# Another comment
326"other@npm:^2.0.0":
327 version: "2.0.0"`
328 const result = parseYarnBerryLock(content)
329 expect(result.get('test')).toContain('1.0.0')
330 expect(result.get('other')).toContain('2.0.0')
331 })
332
333 it('parseYarnBerryLock handles single quotes in specifiers', () => {
334 const content = `'test@npm:^1.0.0':
335 version: '1.0.0'`
336 const result = parseYarnBerryLock(content)
337 expect(result.get('test')).toContain('1.0.0')
338 })
339
340 it('parseYarnBerryLock handles lines without colon', () => {
341 const content = `"test@npm:^1.0.0"
342 version: "1.0.0"
343
344"valid@npm:^2.0.0":
345 version: "2.0.0"`
346 const result = parseYarnBerryLock(content)
347 // Only valid should be parsed since test line doesn't end with ':'
348 expect(result.get('valid')).toContain('2.0.0')
349 expect(result.has('test')).toBe(false)
350 expect(result.size).toBe(1)
351 })
352})
353
354describe('parseBunLock', () => {
355// Test parseBunLock edge cases
356 it('parseBunLock handles malformed JSON', () => {
357 const result = parseBunLock('invalid json {')
358 expect(result).toBeInstanceOf(Map)
359 expect(result.size).toBe(0)
360 })
361
362 it('parseBunLock handles JSONC with comments', () => {
363 const content = `{
364 // This is a comment
365 "packages": [
366 {
367 "name": "test",
368 "version": "1.0.0" // Another comment
369 }
370 ]
371}`
372 const result = parseBunLock(content)
373 expect(result.get('test')).toContain('1.0.0')
374 })
375
376 it('parseBunLock handles malformed JSONC', () => {
377 const content = `{
378 // This is a comment
379 "packages": [
380 invalid json
381 ]
382}`
383 const result = parseBunLock(content)
384 expect(result.size).toBe(0)
385 })
386
387 it('parseBunLock handles missing packages property', () => {
388 const result = parseBunLock('{}')
389 expect(result.size).toBe(0)
390 })
391
392 it('parseBunLock handles null packages', () => {
393 const result = parseBunLock('{"packages": null}')
394 expect(result.size).toBe(0)
395 })
396
397 it('parseBunLock handles array format with null entries', () => {
398 const content = '{"packages": [null, {"name": "test", "version": "1.0.0"}]}'
399 const result = parseBunLock(content)
400 expect(result.get('test')).toContain('1.0.0')
401 expect(result.size).toBe(1)
402 })
403
404 it('parseBunLock handles array format without name or version', () => {
405 const content = '{"packages": [{"description": "test"}]}'
406 const result = parseBunLock(content)
407 expect(result.size).toBe(0)
408 })
409
410 it('parseBunLock handles object format without version', () => {
411 const content = '{"packages": {"test@1.0.0": {"name": "test"}}}'
412 const result = parseBunLock(content)
413 expect(result.size).toBe(0)
414 })
415
416 it('parseBunLock handles object format without name but with key pattern', () => {
417 const content = '{"packages": {"test@1.0.0": {"version": "1.0.0"}}}'
418 const result = parseBunLock(content)
419 expect(result.get('test')).toContain('1.0.0')
420 })
421
422 it('parseBunLock handles scoped packages in key pattern', () => {
423 const content = '{"packages": {"@scope/test@1.0.0": {"version": "1.0.0"}}}'
424 const result = parseBunLock(content)
425 expect(result.get('@scope/test')).toContain('1.0.0')
426 })
427
428 it('parseBunLock handles invalid key patterns', () => {
429 const content = '{"packages": {"invalid-key": {"version": "1.0.0"}}}'
430 const result = parseBunLock(content)
431 expect(result.size).toBe(0)
432 })
433
434 it('parseBunLock handles non-array non-object packages', () => {
435 const content = '{"packages": "invalid"}'
436 const result = parseBunLock(content)
437 expect(result.size).toBe(0)
438 })
439})
440
441describe('findLockfileLine', () => {
442 it('findLockfileLine finds npm lockfile by node_modules key', () => {
443 const content = '{"packages": {"node_modules/test": {"version": "1.0.0"}}}'
444 const line = findLockfileLine('package-lock.json', content, 'test', '1.0.0')
445 expect(typeof line).toBe('number')
446 })
447
448 it('findLockfileLine finds npm lockfile by name field', () => {
449 const content = '{"packages": {"": {}, "x": {"name": "test", "version": "1.0.0"}}}'
450 const line = findLockfileLine('package-lock.json', content, 'test', '1.0.0')
451 expect(typeof line).toBe('number')
452 })
453
454 it('findLockfileLine finds pnpm lockfile primary pattern', () => {
455 const content = 'packages:\n\n /test@1.0.0:\n resolution: {integrity: sha512-test}'
456 const line = findLockfileLine('pnpm-lock.yaml', content, 'test', '1.0.0')
457 expect(line).toBe(3)
458 })
459
460 it('findLockfileLine finds pnpm lockfile secondary pattern (trimStart startsWith)', () => {
461 const content = 'packages:\n\n /test@1.0.0(peer@2.0.0):\n resolution: {integrity: sha512-test}'
462 const line = findLockfileLine('pnpm-lock.yaml', content, 'test', '1.0.0')
463 expect(typeof line).toBe('number')
464 })
465
466 it('findLockfileLine finds yarn v1 lockfile matching version', () => {
467 const content = '# yarn lockfile v1\n\n"test@^1.0.0":\n version "1.0.0"\n\nother@^1.0.0:\n version "1.0.0"'
468 const line = findLockfileLine('yarn.lock', content, 'test', '1.0.0')
469 expect(typeof line).toBe('number')
470 })
471
472 it('findLockfileLine in yarn v1 breaks when next header encountered', () => {
473 const content = '# yarn lockfile v1\n\n"test@^1.0.0":\n resolved "x"\nother@^1.0.0:\n version "1.0.0"'
474 const line = findLockfileLine('yarn.lock', content, 'test', '2.0.0')
475 expect(line).toBe(undefined)
476 })
477
478 it('findLockfileLine finds yarn berry lockfile matching version', () => {
479 const content = '"test@npm:^1.0.0":\n version: "1.0.0"\n"other@npm:^1.0.0":\n version: "1.0.0"'
480 const line = findLockfileLine('yarn.lock', content, 'test', '1.0.0')
481 expect(typeof line).toBe('number')
482 })
483
484 it('findLockfileLine finds bun lockfile by name then version and breaks at closing brace when not found', () => {
485 const content = '{\n "packages": [\n {\n "name": "test",\n "desc": "noop"\n }\n ]\n}'
486 const line = findLockfileLine('bun.lock', content, 'test', '9.9.9')
487 expect(line).toBeUndefined()
488 })
489
490 it('findLockfileLine finds bun lockfile by array name and version', () => {
491 const content = '{\n "packages": [\n {\n "name": "test",\n "version": "1.0.0"\n }\n ]\n}'
492 const line = findLockfileLine('bun.lock', content, 'test', '1.0.0')
493 expect(typeof line).toBe('number')
494 })
495
496 it('findLockfileLine finds bun lockfile by key pattern', () => {
497 const content = '{"packages": {"test@1.0.0": {"something": 1}}}'
498 const line = findLockfileLine('bun.lock', content, 'test', '1.0.0')
499 expect(typeof line).toBe('number')
500 })
501
502 it('findLockfileLine finds bun lockfile by version regex fallback', () => {
503 const content = '{"x": 1}\n{"y": 2}\n "version": "1.0.0"\n{"z": 3}'
504 const line = findLockfileLine('bun.lock', content, 'noname', '1.0.0')
505 expect(typeof line).toBe('number')
506 })
507})
508
509describe('parsePnpmWorkspaceConfig', () => {
510 it('returns empty config when file does not exist', () => {
511 const dir = mkdtempSync(join(tmpdir(), 'provenance-action-'))
512 try {
513 const result = parsePnpmWorkspaceConfig(dir)
514 expect(result).toEqual({})
515 }
516 finally {
517 rmSync(dir, { recursive: true, force: true })
518 }
519 })
520
521 it('parses trustPolicyExclude from pnpm-workspace.yaml', () => {
522 const dir = mkdtempSync(join(tmpdir(), 'provenance-action-'))
523 try {
524 const content = `trustPolicyExclude:
525 - 'chokidar@4.0.3'
526 - 'webpack@4.47.0 || 5.102.1'
527 - '@babel/core@7.28.5'
528`
529 writeFileSync(join(dir, 'pnpm-workspace.yaml'), content, 'utf8')
530 const result = parsePnpmWorkspaceConfig(dir)
531 expect(result.trustPolicyExclude).toEqual([
532 'chokidar@4.0.3',
533 'webpack@4.47.0 || 5.102.1',
534 '@babel/core@7.28.5',
535 ])
536 }
537 finally {
538 rmSync(dir, { recursive: true, force: true })
539 }
540 })
541})
542
543describe('parseTrustPolicyExclude', () => {
544 it('parses list format with single quotes', () => {
545 const content = `trustPolicyExclude:
546 - 'chokidar@4.0.3'
547 - 'webpack'
548`
549 const result = parseTrustPolicyExclude(content)
550 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3', 'webpack'])
551 })
552
553 it('parses list format with double quotes', () => {
554 const content = `trustPolicyExclude:
555 - "chokidar@4.0.3"
556 - "webpack@5.0.0"
557`
558 const result = parseTrustPolicyExclude(content)
559 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3', 'webpack@5.0.0'])
560 })
561
562 it('parses list format without quotes', () => {
563 const content = `trustPolicyExclude:
564 - chokidar@4.0.3
565 - webpack
566`
567 const result = parseTrustPolicyExclude(content)
568 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3', 'webpack'])
569 })
570
571 it('parses inline array format', () => {
572 const content = `trustPolicyExclude: ['chokidar@4.0.3', 'webpack']`
573 const result = parseTrustPolicyExclude(content)
574 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3', 'webpack'])
575 })
576
577 it('parses inline array format with double quotes', () => {
578 const content = `trustPolicyExclude: ["chokidar@4.0.3", "webpack"]`
579 const result = parseTrustPolicyExclude(content)
580 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3', 'webpack'])
581 })
582
583 it('handles empty content', () => {
584 const result = parseTrustPolicyExclude('')
585 expect(result).toEqual({})
586 })
587
588 it('handles content without trustPolicyExclude', () => {
589 const content = `packages:
590 - 'packages/*'
591`
592 const result = parseTrustPolicyExclude(content)
593 expect(result).toEqual({})
594 })
595
596 it('handles comments', () => {
597 const content = `# This is a comment
598trustPolicyExclude:
599 # Another comment
600 - chokidar@4.0.3
601`
602 const result = parseTrustPolicyExclude(content)
603 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3'])
604 })
605
606 it('stops parsing at next top-level key', () => {
607 const content = `trustPolicyExclude:
608 - chokidar@4.0.3
609packages:
610 - 'packages/*'
611`
612 const result = parseTrustPolicyExclude(content)
613 expect(result.trustPolicyExclude).toEqual(['chokidar@4.0.3'])
614 })
615
616 it('handles disjunction patterns', () => {
617 const content = `trustPolicyExclude:
618 - 'webpack@4.47.0 || 5.102.1'
619`
620 const result = parseTrustPolicyExclude(content)
621 expect(result.trustPolicyExclude).toEqual(['webpack@4.47.0 || 5.102.1'])
622 })
623})
624
625describe('matchesTrustPolicyExclude', () => {
626 it('matches package without version (any version)', () => {
627 const patterns = ['chokidar']
628 expect(matchesTrustPolicyExclude('chokidar', '4.0.3', patterns)).toBe(true)
629 expect(matchesTrustPolicyExclude('chokidar', '3.0.0', patterns)).toBe(true)
630 expect(matchesTrustPolicyExclude('other', '1.0.0', patterns)).toBe(false)
631 })
632
633 it('matches package with specific version', () => {
634 const patterns = ['chokidar@4.0.3']
635 expect(matchesTrustPolicyExclude('chokidar', '4.0.3', patterns)).toBe(true)
636 expect(matchesTrustPolicyExclude('chokidar', '3.0.0', patterns)).toBe(false)
637 expect(matchesTrustPolicyExclude('other', '4.0.3', patterns)).toBe(false)
638 })
639
640 it('matches scoped packages', () => {
641 const patterns = ['@babel/core@7.28.5']
642 expect(matchesTrustPolicyExclude('@babel/core', '7.28.5', patterns)).toBe(true)
643 expect(matchesTrustPolicyExclude('@babel/core', '7.0.0', patterns)).toBe(false)
644 expect(matchesTrustPolicyExclude('@babel/preset-env', '7.28.5', patterns)).toBe(false)
645 })
646
647 it('matches scoped packages without version', () => {
648 const patterns = ['@babel/core']
649 expect(matchesTrustPolicyExclude('@babel/core', '7.28.5', patterns)).toBe(true)
650 expect(matchesTrustPolicyExclude('@babel/core', '7.0.0', patterns)).toBe(true)
651 expect(matchesTrustPolicyExclude('@babel/preset-env', '7.28.5', patterns)).toBe(false)
652 })
653
654 it('matches disjunction patterns', () => {
655 const patterns = ['webpack@4.47.0 || 5.102.1']
656 expect(matchesTrustPolicyExclude('webpack', '4.47.0', patterns)).toBe(true)
657 expect(matchesTrustPolicyExclude('webpack', '5.102.1', patterns)).toBe(true)
658 expect(matchesTrustPolicyExclude('webpack', '5.0.0', patterns)).toBe(false)
659 expect(matchesTrustPolicyExclude('other', '4.47.0', patterns)).toBe(false)
660 })
661
662 it('matches multiple patterns', () => {
663 const patterns = ['chokidar@4.0.3', 'webpack', '@babel/core@7.28.5']
664 expect(matchesTrustPolicyExclude('chokidar', '4.0.3', patterns)).toBe(true)
665 expect(matchesTrustPolicyExclude('webpack', '5.0.0', patterns)).toBe(true)
666 expect(matchesTrustPolicyExclude('@babel/core', '7.28.5', patterns)).toBe(true)
667 expect(matchesTrustPolicyExclude('lodash', '4.0.0', patterns)).toBe(false)
668 })
669
670 it('returns false for empty patterns', () => {
671 expect(matchesTrustPolicyExclude('chokidar', '4.0.3', [])).toBe(false)
672 })
673})