microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/claude-support

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/tests/security/Test-DependencyPinning.Tests.ps1

712lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5BeforeAll {
6 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
7
8 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
9 Import-Module $mockPath -Force
10
11 # Fixture paths
12 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Workflows'
13 $script:SecurityFixturesPath = Join-Path $PSScriptRoot '../Fixtures/Security'
14}
15
16Describe 'Test-SHAPinning' -Tag 'Unit' {
17 Context 'Valid SHA references for github-actions' {
18 It 'Returns true for valid 40-char lowercase SHA' {
19 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
20 }
21
22 It 'Returns true for valid 40-char mixed case SHA' {
23 Test-SHAPinning -Version 'A5AC7E51B41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
24 }
25 }
26
27 Context 'Invalid SHA references for github-actions' {
28 It 'Returns false for tag reference' {
29 Test-SHAPinning -Version 'v4' -Type 'github-actions' | Should -BeFalse
30 }
31
32 It 'Returns false for branch reference' {
33 Test-SHAPinning -Version 'main' -Type 'github-actions' | Should -BeFalse
34 }
35
36 It 'Returns false for 39-char reference' {
37 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc2' -Type 'github-actions' | Should -BeFalse
38 }
39
40 It 'Returns false for 41-char reference' {
41 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc291' -Type 'github-actions' | Should -BeFalse
42 }
43
44 It 'Returns false for non-hex characters' {
45 Test-SHAPinning -Version 'g5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeFalse
46 }
47 }
48
49 Context 'Unknown type' {
50 It 'Returns false for unknown dependency type' {
51 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'unknown-type' | Should -BeFalse
52 }
53 }
54}
55
56Describe 'Test-ShellDownloadSecurity' -Tag 'Unit' {
57 Context 'Insecure downloads' {
58 It 'Detects curl without checksum verification' {
59 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
60 $fileInfo = @{
61 Path = $testFile
62 Type = 'shell-downloads'
63 RelativePath = 'insecure-download.sh'
64 }
65 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
66 $result | Should -Not -BeNullOrEmpty
67 $result[0].Severity | Should -Be 'warning'
68 }
69 }
70
71 Context 'File not found' {
72 It 'Returns empty array for non-existent file' {
73 $fileInfo = @{
74 Path = 'TestDrive:/nonexistent/file.sh'
75 Type = 'shell-downloads'
76 RelativePath = 'nonexistent/file.sh'
77 }
78 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
79 $result | Should -BeNullOrEmpty
80 }
81 }
82}
83
84Describe 'Get-DependencyViolation' -Tag 'Unit' {
85 Context 'Pinned workflows' {
86 It 'Returns no violations for fully pinned workflow' {
87 $pinnedPath = Join-Path $script:FixturesPath 'pinned-workflow.yml'
88 $fileInfo = @{
89 Path = $pinnedPath
90 Type = 'github-actions'
91 RelativePath = 'pinned-workflow.yml'
92 }
93 $result = Get-DependencyViolation -FileInfo $fileInfo
94 $result | Should -BeNullOrEmpty
95 }
96 }
97
98 Context 'Unpinned workflows' {
99 It 'Detects unpinned action references' {
100 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
101 $fileInfo = @{
102 Path = $unpinnedPath
103 Type = 'github-actions'
104 RelativePath = 'unpinned-workflow.yml'
105 }
106 $result = Get-DependencyViolation -FileInfo $fileInfo
107 $result | Should -Not -BeNullOrEmpty
108 $result.Count | Should -BeGreaterThan 0
109 }
110
111 It 'Returns correct violation type for unpinned actions' {
112 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
113 $fileInfo = @{
114 Path = $unpinnedPath
115 Type = 'github-actions'
116 RelativePath = 'unpinned-workflow.yml'
117 }
118 $result = Get-DependencyViolation -FileInfo $fileInfo
119 $result[0].Type | Should -Be 'github-actions'
120 }
121 }
122
123 Context 'Mixed workflows' {
124 It 'Detects only unpinned actions in mixed workflow' {
125 $mixedPath = Join-Path $script:FixturesPath 'mixed-pinning-workflow.yml'
126 $fileInfo = @{
127 Path = $mixedPath
128 Type = 'github-actions'
129 RelativePath = 'mixed-pinning-workflow.yml'
130 }
131 $result = Get-DependencyViolation -FileInfo $fileInfo
132 $result | Should -Not -BeNullOrEmpty
133 # Should only detect the unpinned setup-node action
134 $result.Name | Should -Contain 'actions/setup-node'
135 }
136 }
137
138 Context 'Non-existent file' {
139 It 'Returns empty array for non-existent file' {
140 $fileInfo = @{
141 Path = 'TestDrive:/nonexistent/file.yml'
142 Type = 'github-actions'
143 RelativePath = 'file.yml'
144 }
145 $result = Get-DependencyViolation -FileInfo $fileInfo
146 $result | Should -BeNullOrEmpty
147 }
148 }
149}
150
151Describe 'Export-ComplianceReport' -Tag 'Unit' {
152 BeforeEach {
153 $script:TestOutputPath = Join-Path $TestDrive 'report'
154 New-Item -ItemType Directory -Path $script:TestOutputPath -Force | Out-Null
155
156 # Create a proper ComplianceReport class instance
157 $script:MockReport = [ComplianceReport]::new()
158 $script:MockReport.ScanPath = $script:FixturesPath
159 $script:MockReport.ComplianceScore = 50
160 $script:MockReport.TotalFiles = 3
161 $script:MockReport.ScannedFiles = 3
162 $script:MockReport.TotalDependencies = 4
163 $script:MockReport.PinnedDependencies = 2
164 $script:MockReport.UnpinnedDependencies = 2
165 $script:MockReport.Violations = @(
166 [PSCustomObject]@{
167 File = 'unpinned-workflow.yml'
168 Line = 10
169 Type = 'github-actions'
170 Name = 'actions/checkout'
171 Version = 'v4'
172 Severity = 'High'
173 Description = 'Unpinned dependency'
174 Remediation = 'Pin to SHA'
175 }
176 )
177 $script:MockReport.Summary = @{
178 'github-actions' = @{
179 Total = 4
180 High = 2
181 Medium = 0
182 Low = 0
183 }
184 }
185 }
186
187 Context 'JSON format' {
188 It 'Generates valid JSON report' {
189 $outputFile = Join-Path $script:TestOutputPath 'report.json'
190
191 Export-ComplianceReport -Report $script:MockReport -Format 'json' -OutputPath $outputFile
192
193 Test-Path $outputFile | Should -BeTrue
194 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
195 $content | Should -Not -BeNullOrEmpty
196 }
197 }
198
199 Context 'SARIF format' {
200 It 'Generates valid SARIF report' {
201 $outputFile = Join-Path $script:TestOutputPath 'report.sarif'
202
203 Export-ComplianceReport -Report $script:MockReport -Format 'sarif' -OutputPath $outputFile
204
205 Test-Path $outputFile | Should -BeTrue
206 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
207 $content.'$schema' | Should -Match 'sarif'
208 }
209 }
210
211 Context 'Table format' {
212 It 'Generates table output without error' {
213 $outputFile = Join-Path $script:TestOutputPath 'report.txt'
214
215 { Export-ComplianceReport -Report $script:MockReport -Format 'table' -OutputPath $outputFile } | Should -Not -Throw
216 Test-Path $outputFile | Should -BeTrue
217 }
218 }
219
220 Context 'CSV format' {
221 It 'Generates CSV report' {
222 $outputFile = Join-Path $script:TestOutputPath 'report.csv'
223
224 Export-ComplianceReport -Report $script:MockReport -Format 'csv' -OutputPath $outputFile
225
226 Test-Path $outputFile | Should -BeTrue
227 }
228 }
229
230 Context 'Markdown format' {
231 It 'Generates Markdown report' {
232 $outputFile = Join-Path $script:TestOutputPath 'report.md'
233
234 Export-ComplianceReport -Report $script:MockReport -Format 'markdown' -OutputPath $outputFile
235
236 Test-Path $outputFile | Should -BeTrue
237 $content = Get-Content $outputFile -Raw
238 $content | Should -Match '# Dependency Pinning Compliance Report'
239 }
240 }
241}
242
243Describe 'ExcludePaths Filtering Logic' -Tag 'Unit' {
244 Context 'Pattern matching with -notlike operator' {
245 It 'Excludes paths containing pattern using -notlike wildcard' {
246 # Test the exclusion logic used in Get-FilesToScan:
247 # $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
248 $testPaths = @(
249 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
250 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
251 )
252
253 $exclude = 'vendor'
254 $filtered = $testPaths | Where-Object { $_.FullName -notlike "*$exclude*" }
255
256 $filtered.Count | Should -Be 1
257 $filtered[0].FullName | Should -Not -Match 'vendor'
258 }
259
260 It 'Excludes multiple patterns correctly' {
261 $testPaths = @(
262 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
263 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
264 @{ FullName = 'C:\repo\node_modules\pkg\workflow.yml' }
265 )
266
267 $excludePatterns = @('vendor', 'node_modules')
268 $filtered = $testPaths
269 foreach ($exclude in $excludePatterns) {
270 $filtered = @($filtered | Where-Object { $_.FullName -notlike "*$exclude*" })
271 }
272
273 $filtered.Count | Should -Be 1
274 $filtered[0].FullName | Should -Be 'C:\repo\.github\workflows\test.yml'
275 }
276 }
277
278 Context 'Processes all files when ExcludePatterns is empty' {
279 It 'Returns all paths when no exclusion patterns provided' {
280 $testPaths = @(
281 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
282 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
283 )
284
285 $excludePatterns = @()
286 $filtered = $testPaths
287 if ($excludePatterns) {
288 foreach ($exclude in $excludePatterns) {
289 $filtered = $filtered | Where-Object { $_.FullName -notlike "*$exclude*" }
290 }
291 }
292
293 $filtered.Count | Should -Be 2
294 }
295 }
296
297 Context 'Comma-separated pattern parsing in main script' {
298 It 'Parses comma-separated exclude paths correctly' {
299 # Test the pattern used in main execution: $ExcludePaths.Split(',')
300 $excludePathsParam = 'vendor,node_modules,dist'
301 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
302
303 $patterns.Count | Should -Be 3
304 $patterns | Should -Contain 'vendor'
305 $patterns | Should -Contain 'node_modules'
306 $patterns | Should -Contain 'dist'
307 }
308
309 It 'Handles single pattern without comma' {
310 $excludePathsParam = 'vendor'
311 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
312
313 $patterns.Count | Should -Be 1
314 $patterns | Should -Contain 'vendor'
315 }
316
317 It 'Handles empty exclude paths' {
318 $excludePathsParam = ''
319 $patterns = if ($excludePathsParam) { $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
320
321 $patterns.Count | Should -Be 0
322 }
323 }
324
325 Context 'Pattern matching behavior' {
326 It 'Uses -notlike with wildcard for exclusion' {
327 $filePath = 'C:\repo\vendor\.github\workflows\test.yml'
328 $pattern = 'vendor'
329
330 # This matches how Get-FilesToScan uses: $_.FullName -notlike "*$exclude*"
331 $filePath -notlike "*$pattern*" | Should -BeFalse
332 }
333
334 It 'Passes through non-matching paths' {
335 $filePath = 'C:\repo\.github\workflows\main.yml'
336 $pattern = 'vendor'
337
338 $filePath -notlike "*$pattern*" | Should -BeTrue
339 }
340 }
341}
342
343Describe 'Dot-sourced execution protection' -Tag 'Unit' {
344 Context 'When script is dot-sourced' {
345 It 'Does not execute main block when dot-sourced' {
346 # Arrange
347 $testScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
348 $tempOutputPath = Join-Path $TestDrive 'dot-source-test.json'
349
350 # Act - Invoke in new process with dot-sourcing simulation
351 $scriptBlock = ". '$testScript' -OutputPath '$tempOutputPath'; [System.IO.File]::Exists('$tempOutputPath')"
352 pwsh -Command $scriptBlock 2>&1 | Out-Null
353
354 # Assert - Main execution should be skipped, no output file created
355 Test-Path $tempOutputPath | Should -BeFalse
356 }
357
358 It 'Writes error message when dot-sourced' {
359 # Arrange
360 $testScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
361
362 # Act - Invoke in new process to test dot-sourcing error handling
363 $result = pwsh -Command ". '$testScript'" 2>&1
364 $errorOutput = $result | Where-Object { $_ -match 'dot-sourced' -or $_ -match 'will not execute' }
365
366 # Assert - Should write error message about dot-sourcing
367 $errorOutput | Should -Not -BeNullOrEmpty
368 }
369 }
370}
371
372Describe 'GitHub Actions error annotation' {
373 BeforeAll {
374 $script:OriginalGHA = $env:GITHUB_ACTIONS
375 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
376 }
377
378 AfterAll {
379 if ($null -eq $script:OriginalGHA) {
380 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
381 } else {
382 $env:GITHUB_ACTIONS = $script:OriginalGHA
383 }
384 }
385
386 Context 'Error handling with GitHub Actions' {
387 It 'Outputs GitHub error annotation on failure' {
388 # Arrange - Create a corrupted workflow file that will trigger an error
389 $testWorkflowDir = Join-Path $TestDrive 'test-workflows'
390 New-Item -ItemType Directory -Path (Join-Path $testWorkflowDir '.github/workflows') -Force | Out-Null
391 $corruptedFile = Join-Path $testWorkflowDir '.github/workflows/test.yml'
392 "uses: actions/checkout@invalid!!!" | Out-File -FilePath $corruptedFile -Encoding UTF8
393
394 # Act - Run script in new process with GITHUB_ACTIONS set
395 $scriptCommand = @"
396`$env:GITHUB_ACTIONS = 'true'
397& '$script:TestScript' -Path '$testWorkflowDir' -Format 'json' -OutputPath '$TestDrive/gha-test.json' -FailOnUnpinned 2>&1
398"@
399 $output = pwsh -Command $scriptCommand
400
401 # Assert - Should contain GitHub Actions error annotation or error output
402 # The script should execute and potentially generate warnings/errors
403 $output | Should -Not -BeNullOrEmpty
404 }
405 }
406}
407
408Describe 'Get-ComplianceReportData' -Tag 'Unit' {
409 BeforeAll {
410 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
411 }
412
413 Context 'Array coercion operations' {
414 It 'Handles empty violations array' {
415 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations @() -ScannedFiles @()
416
417 $result.TotalDependencies | Should -Be 0
418 $result.UnpinnedDependencies | Should -Be 0
419 $result.PinnedDependencies | Should -Be 0
420 $result.ComplianceScore | Should -Be 100.0
421 }
422
423 It 'Counts violations correctly with array coercion' {
424 $v1 = [DependencyViolation]::new()
425 $v1.Type = 'github-actions'
426 $v1.Severity = 'High'
427
428 $v2 = [DependencyViolation]::new()
429 $v2.Type = 'github-actions'
430 $v2.Severity = 'Medium'
431
432 $v3 = [DependencyViolation]::new()
433 $v3.Type = 'npm'
434 $v3.Severity = 'High'
435
436 $violations = @($v1, $v2, $v3)
437 $scannedFiles = @(@{ Path = 'test1.yml' }, @{ Path = 'test2.json' })
438
439 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
440
441 $result.TotalDependencies | Should -Be 3
442 $result.UnpinnedDependencies | Should -Be 3
443 }
444
445 It 'Groups violations by type with array coercion' {
446 $v1 = [DependencyViolation]::new()
447 $v1.Type = 'github-actions'
448 $v1.Severity = 'High'
449
450 $v2 = [DependencyViolation]::new()
451 $v2.Type = 'github-actions'
452 $v2.Severity = 'Low'
453
454 $v3 = [DependencyViolation]::new()
455 $v3.Type = 'npm'
456 $v3.Severity = 'Medium'
457
458 $violations = @($v1, $v2, $v3)
459 $scannedFiles = @(@{ Path = 'test.yml' })
460
461 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
462
463 $result.Summary.Keys | Should -Contain 'github-actions'
464 $result.Summary.Keys | Should -Contain 'npm'
465 $result.Summary['github-actions'].Total | Should -Be 2
466 $result.Summary['npm'].Total | Should -Be 1
467 }
468
469 It 'Counts severity levels correctly with array coercion' {
470 $violations = @()
471 for ($i = 0; $i -lt 4; $i++) {
472 $v = [DependencyViolation]::new()
473 $v.Type = 'github-actions'
474 $v.Severity = switch ($i) {
475 0 { 'High' }
476 1 { 'High' }
477 2 { 'Medium' }
478 3 { 'Low' }
479 }
480 $violations += $v
481 }
482 $scannedFiles = @(@{ Path = 'test.yml' })
483
484 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
485
486 $result.Summary['github-actions'].High | Should -Be 2
487 $result.Summary['github-actions'].Medium | Should -Be 1
488 $result.Summary['github-actions'].Low | Should -Be 1
489 }
490
491 It 'Handles single violation without PowerShell unrolling' {
492 $v = [DependencyViolation]::new()
493 $v.Type = 'github-actions'
494 $v.Severity = 'High'
495
496 $violations = @($v)
497 $scannedFiles = @(@{ Path = 'test.yml' })
498
499 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
500
501 $result.TotalDependencies | Should -Be 1
502 $result.Summary['github-actions'].Total | Should -Be 1
503 $result.Summary['github-actions'].High | Should -Be 1
504 }
505 }
506}
507
508Describe 'Main Script Execution' {
509 BeforeAll {
510 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
511 $script:TestWorkspaceDir = Join-Path $TestDrive 'test-workspace'
512 New-Item -ItemType Directory -Path $script:TestWorkspaceDir -Force | Out-Null
513
514 # Create .github/workflows directory
515 $workflowDir = Join-Path $script:TestWorkspaceDir '.github/workflows'
516 New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null
517 }
518
519 Context 'Array coercion in main execution block' {
520 It 'Executes array coercion when scanning files' {
521 # Create test workflow file
522 $workflowContent = @'
523name: Test
524on: push
525jobs:
526 test:
527 runs-on: ubuntu-latest
528 steps:
529 - uses: actions/checkout@v4
530'@
531 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/test.yml') -Value $workflowContent
532
533 $jsonPath = Join-Path $TestDrive 'scan-output.json'
534
535 # Execute script with array coercion operations
536 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
537
538 # Verify output was created (proves array operations executed)
539 Test-Path $jsonPath | Should -BeTrue
540 $result = Get-Content $jsonPath | ConvertFrom-Json
541 $result.PSObject.Properties.Name | Should -Contain 'ComplianceScore'
542 }
543
544 It 'Handles empty scan results with array coercion' {
545 # Remove workflow files
546 Remove-Item -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/*.yml') -Force -ErrorAction SilentlyContinue
547
548 # Create pinned workflow
549 $pinnedContent = @'
550name: Pinned
551on: push
552jobs:
553 test:
554 runs-on: ubuntu-latest
555 steps:
556 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
557'@
558 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/pinned.yml') -Value $pinnedContent
559
560 $jsonPath = Join-Path $TestDrive 'empty-output.json'
561
562 # Execute with all dependencies pinned (tests zero count array coercion)
563 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
564
565 Test-Path $jsonPath | Should -BeTrue
566 $result = Get-Content $jsonPath | ConvertFrom-Json
567 $result.UnpinnedDependencies | Should -Be 0
568 }
569 }
570}
571
572Describe 'Get-NpmDependencyViolations' -Tag 'Unit' {
573 BeforeAll {
574 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
575 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Npm'
576 }
577
578 Context 'Metadata-only package.json' {
579 It 'Returns zero violations for package with no dependencies' {
580 $fileInfo = @{
581 Path = Join-Path $script:FixturesPath 'metadata-only-package.json'
582 Type = 'npm'
583 RelativePath = 'metadata-only-package.json'
584 }
585
586 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
587
588 $violations.Count | Should -Be 0
589 }
590 }
591
592 Context 'Package.json with dependencies' {
593 It 'Detects unpinned dependencies in all sections' {
594 $fileInfo = @{
595 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
596 Type = 'npm'
597 RelativePath = 'with-dependencies-package.json'
598 }
599
600 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
601
602 $violations.Count | Should -BeGreaterThan 0
603 }
604
605 It 'Identifies correct dependency sections' {
606 $fileInfo = @{
607 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
608 Type = 'npm'
609 RelativePath = 'with-dependencies-package.json'
610 }
611
612 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
613 $sections = $violations | ForEach-Object { $_.Metadata.Section } | Sort-Object -Unique
614
615 $sections | Should -Contain 'dependencies'
616 $sections | Should -Contain 'devDependencies'
617 }
618
619 It 'Captures package name and version in violations' {
620 $fileInfo = @{
621 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
622 Type = 'npm'
623 RelativePath = 'with-dependencies-package.json'
624 }
625
626 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
627 $lodashViolation = $violations | Where-Object { $_.Name -eq 'lodash' }
628
629 $lodashViolation | Should -Not -BeNullOrEmpty
630 $lodashViolation.Name | Should -Be 'lodash'
631 $lodashViolation.Version | Should -Be '^4.17.21'
632 }
633 }
634
635 Context 'Non-existent file' {
636 It 'Returns empty array for missing file' {
637 $fileInfo = @{
638 Path = 'C:\nonexistent\package.json'
639 Type = 'npm'
640 RelativePath = 'nonexistent/package.json'
641 }
642
643 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
644
645 $violations.Count | Should -Be 0
646 }
647 }
648
649 Context 'When package.json contains invalid JSON' {
650 BeforeAll {
651 $script:invalidJsonPath = Join-Path $script:FixturesPath 'invalid-json-package.json'
652 }
653
654 It 'Returns empty violations array on parse failure' {
655 $fileInfo = @{
656 Path = $script:invalidJsonPath
657 Type = 'npm'
658 RelativePath = 'invalid-json-package.json'
659 }
660
661 $violations = @(Get-NpmDependencyViolations -FileInfo $fileInfo)
662
663 $violations | Should -HaveCount 0
664 }
665
666 It 'Emits a warning about parse failure' {
667 $fileInfo = @{
668 Path = $script:invalidJsonPath
669 Type = 'npm'
670 RelativePath = 'invalid-json-package.json'
671 }
672
673 $warnings = Get-NpmDependencyViolations -FileInfo $fileInfo 3>&1
674
675 $warnings | Should -Not -BeNullOrEmpty
676 $warnings | Should -Match 'Failed to parse.*as JSON'
677 }
678 }
679
680 Context 'When package.json contains empty or whitespace versions' {
681 BeforeAll {
682 $script:emptyVersionPath = Join-Path $script:FixturesPath 'empty-version-package.json'
683 }
684
685 It 'Skips dependencies with empty versions' {
686 $fileInfo = @{
687 Path = $script:emptyVersionPath
688 Type = 'npm'
689 RelativePath = 'empty-version-package.json'
690 }
691
692 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
693 $packageNames = $violations | ForEach-Object { $_.Name }
694
695 $packageNames | Should -Not -Contain 'empty-version'
696 $packageNames | Should -Not -Contain 'whitespace-version'
697 }
698
699 It 'Reports violations for valid non-pinned versions in same file' {
700 $fileInfo = @{
701 Path = $script:emptyVersionPath
702 Type = 'npm'
703 RelativePath = 'empty-version-package.json'
704 }
705
706 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
707
708 $violations.Count | Should -BeGreaterThan 0
709 $violations | Where-Object { $_.Name -eq 'valid-package' } | Should -Not -BeNullOrEmpty
710 }
711 }
712}
713