microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/a11y-pr6-docs

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1270lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5<#
6.SYNOPSIS
7 Pester tests for Test-SHAStaleness.ps1 functions.
8
9.DESCRIPTION
10 Tests the staleness checking functions without executing the main script.
11 Uses dot-source guard pattern for function isolation.
12#>
13
14BeforeAll {
15 $scriptPath = Join-Path $PSScriptRoot '../../security/Test-SHAStaleness.ps1'
16 . $scriptPath
17 # Re-import CIHelpers so Pester can resolve its commands for mocking;
18 # the nested-module import inside SecurityHelpers shadows the standalone copy.
19 Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1') -Force
20
21 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
22 Import-Module $mockPath -Force
23
24 # Save environment before tests
25 Save-CIEnvironment
26
27 # Fixture paths
28 $script:FixturesPath = Join-Path $PSScriptRoot '../fixtures/Security'
29}
30
31AfterAll {
32 # Restore environment after tests
33 Restore-CIEnvironment
34}
35
36Describe 'Test-GitHubToken' -Tag 'Unit' {
37 BeforeEach {
38 Initialize-MockCIEnvironment
39 }
40
41 AfterEach {
42 Clear-MockCIEnvironment
43 }
44
45 Context 'No token provided' {
46 It 'Returns hashtable with Valid=false when empty token provided' {
47 $result = Test-GitHubToken -Token ''
48 $result | Should -BeOfType [hashtable]
49 $result.Valid | Should -BeFalse
50 }
51
52 It 'Returns Authenticated=false when no token provided' {
53 $result = Test-GitHubToken -Token ''
54 $result.Authenticated | Should -BeFalse
55 }
56
57 It 'Returns rate limit of 0 when no token provided' {
58 $result = Test-GitHubToken -Token ''
59 $result.RateLimit | Should -Be 0
60 }
61 }
62
63 Context 'Invalid token' {
64 BeforeEach {
65 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
66 throw 'Bad credentials'
67 }
68 }
69
70 It 'Returns Valid=false for invalid token' {
71 $result = Test-GitHubToken -Token 'invalid-token'
72 $result.Valid | Should -BeFalse
73 }
74 }
75
76 Context 'Valid token' {
77 BeforeEach {
78 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
79 return @{
80 data = @{
81 viewer = @{ login = 'testuser' }
82 rateLimit = @{ limit = 5000; remaining = 4999; resetAt = '2024-01-01T00:00:00Z' }
83 }
84 }
85 }
86 }
87
88 It 'Returns Valid=true for valid token' {
89 $result = Test-GitHubToken -Token 'ghp_validtoken123456789'
90 $result.Valid | Should -BeTrue
91 }
92
93 It 'Returns user information for valid token' {
94 $result = Test-GitHubToken -Token 'ghp_validtoken123456789'
95 $result.User | Should -Be 'testuser'
96 }
97
98 It 'Returns rate limit information for valid token' {
99 $result = Test-GitHubToken -Token 'ghp_validtoken123456789'
100 $result.RateLimit | Should -Be 5000
101 $result.Remaining | Should -Be 4999
102 }
103 }
104}
105
106Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
107 BeforeEach {
108 Initialize-MockCIEnvironment
109 }
110
111 AfterEach {
112 Clear-MockCIEnvironment
113 }
114
115 Context 'Successful requests' {
116 It 'Returns response on first successful call' {
117 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
118 return @{ data = 'success' }
119 }
120
121 $headers = @{ 'Authorization' = 'Bearer test' }
122 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers $headers -Body '{}'
123 $result.data | Should -Be 'success'
124 }
125 }
126
127 Context 'Non-retryable errors' {
128 It 'Returns null on non-rate-limit errors' {
129 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
130 throw [System.Exception]::new('Network error')
131 }
132
133 $headers = @{ 'Authorization' = 'Bearer test' }
134 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers $headers -Body '{}'
135 $result | Should -BeNullOrEmpty
136 }
137 }
138}
139
140Describe 'Compare-ToolVersion' -Tag 'Unit' {
141 Context 'Semantic version comparison' {
142 It 'Returns true when latest is newer major version' {
143 Compare-ToolVersion -Current '1.0.0' -Latest '2.0.0' | Should -BeTrue
144 }
145
146 It 'Returns true when latest is newer minor version' {
147 Compare-ToolVersion -Current '1.0.0' -Latest '1.1.0' | Should -BeTrue
148 }
149
150 It 'Returns true when latest is newer patch version' {
151 Compare-ToolVersion -Current '1.0.0' -Latest '1.0.1' | Should -BeTrue
152 }
153
154 It 'Returns false when current equals latest' {
155 Compare-ToolVersion -Current '1.0.0' -Latest '1.0.0' | Should -BeFalse
156 }
157
158 It 'Returns false when current is newer than latest' {
159 Compare-ToolVersion -Current '2.0.0' -Latest '1.0.0' | Should -BeFalse
160 }
161
162 It 'Handles major version differences correctly' {
163 Compare-ToolVersion -Current '7.0.0' -Latest '8.0.0' | Should -BeTrue
164 }
165
166 It 'Handles minor version differences correctly' {
167 Compare-ToolVersion -Current '8.17.0' -Latest '8.18.0' | Should -BeTrue
168 }
169
170 It 'Handles patch version differences correctly' {
171 Compare-ToolVersion -Current '8.18.1' -Latest '8.18.2' | Should -BeTrue
172 }
173 }
174
175 Context 'Version with v prefix' {
176 It 'Handles v-prefixed versions' {
177 Compare-ToolVersion -Current 'v1.0.0' -Latest 'v2.0.0' | Should -BeTrue
178 }
179
180 It 'Handles mixed v-prefix versions' {
181 Compare-ToolVersion -Current '1.0.0' -Latest 'v2.0.0' | Should -BeTrue
182 }
183
184 It 'Returns false for equal v-prefixed versions' {
185 Compare-ToolVersion -Current 'v1.0.0' -Latest 'v1.0.0' | Should -BeFalse
186 }
187 }
188
189 Context 'Pre-release versions' {
190 It 'Strips pre-release metadata for comparison' {
191 Compare-ToolVersion -Current '1.0.0-alpha' -Latest '1.0.0' | Should -BeFalse
192 }
193
194 It 'Handles build metadata' {
195 Compare-ToolVersion -Current '1.0.0+build123' -Latest '2.0.0' | Should -BeTrue
196 }
197 }
198}
199
200Describe 'Get-ToolStaleness' -Tag 'Integration', 'RequiresNetwork' {
201 Context 'With mock manifest' {
202 BeforeEach {
203 # Create a temporary manifest file
204 $script:TempManifest = Join-Path $TestDrive 'tool-checksums.json'
205 $manifestContent = @{
206 tools = @(
207 @{
208 name = 'test-tool'
209 repo = 'test-org/test-repo'
210 version = '1.0.0'
211 sha256 = 'abc123'
212 notes = 'Test tool'
213 }
214 )
215 } | ConvertTo-Json -Depth 10
216 Set-Content -Path $script:TempManifest -Value $manifestContent
217 }
218
219 It 'Returns results array' -Skip:$true {
220 # Skip by default - requires actual GitHub API access
221 $result = Get-ToolStaleness -ManifestPath $script:TempManifest
222 $result | Should -BeOfType [System.Object[]]
223 }
224 }
225
226 Context 'Missing manifest' {
227 It 'Handles missing manifest gracefully' {
228 $result = Get-ToolStaleness -ManifestPath 'TestDrive:/nonexistent/manifest.json'
229 $result | Should -BeNullOrEmpty
230 }
231 }
232}
233
234Describe 'Get-PSModuleStaleness' -Tag 'Unit' {
235 Context 'With mock manifest and mocked PSGallery' {
236 BeforeEach {
237 $script:TempManifest = Join-Path $TestDrive 'tool-checksums.json'
238 $manifestContent = @{
239 psModules = @(
240 @{ name = 'PowerShell-Yaml'; version = '0.4.7'; notes = 'YAML parser' }
241 @{ name = 'Pester'; version = '5.7.1'; notes = 'Test framework' }
242 )
243 } | ConvertTo-Json -Depth 10
244 Set-Content -Path $script:TempManifest -Value $manifestContent
245
246 Mock Invoke-RestMethod {
247 if ($Uri -match "Id eq '([^']+)'") {
248 $name = $Matches[1]
249 $latest = switch ($name) {
250 'PowerShell-Yaml' { '0.4.12' }
251 'Pester' { '5.7.1' }
252 default { '0.0.0' }
253 }
254 return @{ properties = @{ Version = $latest } }
255 }
256 return @{}
257 }
258 }
259
260 It 'Returns one result per pinned module' {
261 $result = @(Get-PSModuleStaleness -ManifestPath $script:TempManifest)
262 $result.Count | Should -Be 2
263 }
264
265 It 'Flags modules with newer versions as stale' {
266 $result = @(Get-PSModuleStaleness -ManifestPath $script:TempManifest)
267 $yaml = $result | Where-Object { $_.Module -eq 'PowerShell-Yaml' }
268 $yaml.IsStale | Should -BeTrue
269 $yaml.LatestVersion | Should -Be '0.4.12'
270 }
271
272 It 'Marks modules at the latest version as not stale' {
273 $result = @(Get-PSModuleStaleness -ManifestPath $script:TempManifest)
274 $pester = $result | Where-Object { $_.Module -eq 'Pester' }
275 $pester.IsStale | Should -BeFalse
276 }
277
278 It 'Captures errors when PSGallery throws' {
279 Mock Invoke-RestMethod { throw 'network down' }
280 $result = @(Get-PSModuleStaleness -ManifestPath $script:TempManifest)
281 $result[0].Error | Should -Match 'network down'
282 $result[0].LatestVersion | Should -BeNullOrEmpty
283 }
284 }
285
286 Context 'Manifest without psModules' {
287 It 'Returns empty when psModules array is absent' {
288 $manifestPath = Join-Path $TestDrive 'tools-only.json'
289 @{ tools = @() } | ConvertTo-Json | Set-Content -Path $manifestPath
290 $result = Get-PSModuleStaleness -ManifestPath $manifestPath
291 $result | Should -BeNullOrEmpty
292 }
293 }
294
295 Context 'Missing manifest' {
296 It 'Handles missing manifest gracefully' {
297 $result = Get-PSModuleStaleness -ManifestPath 'TestDrive:/nonexistent/manifest.json'
298 $result | Should -BeNullOrEmpty
299 }
300 }
301
302 Context 'With malformed psModules entries' {
303 It 'Reports missing name field without throwing' {
304 $manifestPath = Join-Path $TestDrive 'malformed-name.json'
305 @{ psModules = @(@{ version = '1.0.0'; notes = 'missing name' }) } |
306 ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath
307 $result = @(Get-PSModuleStaleness -ManifestPath $manifestPath)
308 $result[0].Module | Should -Be '<unnamed>'
309 $result[0].IsStale | Should -BeNullOrEmpty
310 $result[0].Error | Should -Match 'name'
311 }
312
313 It 'Reports missing version field without throwing' {
314 $manifestPath = Join-Path $TestDrive 'malformed-ver.json'
315 @{ psModules = @(@{ name = 'SomeModule'; notes = 'missing version' }) } |
316 ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath
317 $result = @(Get-PSModuleStaleness -ManifestPath $manifestPath)
318 $result[0].Module | Should -Be 'SomeModule'
319 $result[0].IsStale | Should -BeNullOrEmpty
320 $result[0].Error | Should -Match 'version'
321 }
322 }
323}
324
325Describe 'Main Script Execution' {
326 BeforeAll {
327 # Create test repo structure (script expects .github/workflows from current directory)
328 $script:TestRepo = Join-Path $TestDrive 'test-repo'
329 $script:WorkflowDir = Join-Path $script:TestRepo '.github' 'workflows'
330 New-Item -ItemType Directory -Path $script:WorkflowDir -Force | Out-Null
331
332 # Create logs directory
333 $logsDir = Join-Path $script:TestRepo 'logs'
334 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
335
336 # Create test manifest in scripts/security location
337 $manifestDir = Join-Path $script:TestRepo 'scripts' 'security'
338 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
339 $script:ManifestPath = Join-Path $manifestDir 'tool-checksums.json'
340 @{
341 tools = @(
342 @{
343 name = 'pwsh'
344 repo = 'PowerShell/PowerShell'
345 version = '7.4.0'
346 sha256 = 'test-sha'
347 notes = 'PowerShell'
348 }
349 )
350 psModules = @(
351 @{ name = 'Pester'; version = '5.7.1'; notes = 'Test framework' }
352 )
353 } | ConvertTo-Json -Depth 10 | Set-Content -Path $script:ManifestPath
354
355 # Save current directory
356 $script:OriginalLocation = Get-Location
357
358 # Script-scope safety-net mock -- no direct Invoke-RestMethod calls remain in Test-SHAStaleness.ps1
359 # after consolidation; all REST calls route through SecurityHelpers via Invoke-GitHubAPIWithRetry.
360 Mock Invoke-RestMethod {
361 if ($Uri -like '*/releases/latest') {
362 $repoName = ($Uri -split '/')[-3]
363 return @{
364 tag_name = switch ($repoName) {
365 'actionlint' { 'v1.7.10' }
366 'gitleaks' { 'v8.30.0' }
367 'cosign' { 'v3.0.5' }
368 default { 'v1.0.0' }
369 }
370 published_at = (Get-Date).AddMonths(-1).ToString('o')
371 }
372 }
373 elseif ($Uri -match "Id eq '([^']+)'") {
374 # PowerShell Gallery OData lookup for pinned modules. Per-module versions
375 # matching the manifest pins so module checks do not produce false stale entries.
376 $modName = $Matches[1]
377 $modVersion = switch ($modName) {
378 'PowerShell-Yaml' { '0.4.7' }
379 'Pester' { '5.7.1' }
380 'PSScriptAnalyzer' { '1.25.0' }
381 default { '0.0.0' }
382 }
383 return @{ properties = @{ Version = $modVersion } }
384 }
385 elseif ($Uri -like '*/repos/*/branches/*') {
386 return @{ commit = @{ sha = '9999999999999999999999999999999999999999' } }
387 }
388 elseif ($Uri -like '*/repos/*/commits/*') {
389 return @{ commit = @{ author = @{ date = (Get-Date).AddDays(-60).ToString('o') } } }
390 }
391 elseif ($Uri -like '*/repos/*') {
392 return @{ default_branch = 'main' }
393 }
394 return @{}
395 }
396
397 # Module-scope mock intercepts Invoke-RestMethod calls made inside SecurityHelpers
398 # (Test-GitHubToken and Invoke-GitHubAPIWithRetry both call Invoke-RestMethod internally).
399 # Uses aliased GraphQL response structure matching the script's batch queries.
400 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
401 if ($Uri -like '*graphql*') {
402 if ($Body -match 'viewer') {
403 # Test-GitHubToken validation query
404 return @{
405 data = @{
406 viewer = @{ login = 'testuser' }
407 rateLimit = @{ remaining = 5000; limit = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
408 }
409 }
410 }
411 elseif ($Body -match 'defaultBranchRef') {
412 # Repo batch query -- aliased structure (repo0, repo1, ...)
413 return @{
414 data = @{
415 rateLimit = @{ remaining = 5000; limit = 5000; used = 1; resetAt = (Get-Date).AddHours(1).ToString('o') }
416 repo0 = @{ name = 'checkout'; defaultBranchRef = @{ target = @{ oid = '9999999999999999999999999999999999999999'; committedDate = (Get-Date).ToString('o') } } }
417 repo1 = @{ name = 'setup-node'; defaultBranchRef = @{ target = @{ oid = '8888888888888888888888888888888888888888'; committedDate = (Get-Date).ToString('o') } } }
418 }
419 }
420 }
421 else {
422 # Commit batch query -- PSCustomObject required for PSObject.Properties iteration
423 return [PSCustomObject]@{
424 data = [PSCustomObject]@{
425 rateLimit = [PSCustomObject]@{ remaining = 5000; cost = 1 }
426 commit0 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; committedDate = (Get-Date).AddDays(-60).ToString('o') } }
427 commit1 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c'; committedDate = (Get-Date).AddDays(-45).ToString('o') } }
428 }
429 }
430 }
431 }
432 elseif ($Uri -like '*/releases/latest') {
433 $repoName = ($Uri -split '/')[-3]
434 return @{
435 tag_name = switch ($repoName) {
436 'actionlint' { 'v1.7.10' }
437 'gitleaks' { 'v8.30.0' }
438 'cosign' { 'v3.0.5' }
439 default { 'v1.0.0' }
440 }
441 published_at = (Get-Date).AddMonths(-1).ToString('o')
442 }
443 }
444 return @{}
445 }
446
447 # Script-scope mock for Invoke-GitHubAPIWithRetry -- prevents real HTTP calls
448 # when Import-Module -Force in the production script resets module-scope mocks.
449 Mock Invoke-GitHubAPIWithRetry {
450 if ($Uri -like '*graphql*') {
451 if ($Body -match 'viewer') {
452 return @{
453 data = @{
454 viewer = @{ login = 'testuser' }
455 rateLimit = @{ remaining = 5000; limit = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
456 }
457 }
458 }
459 elseif ($Body -match 'defaultBranchRef') {
460 return @{
461 data = @{
462 rateLimit = @{ remaining = 5000; limit = 5000; used = 1; resetAt = (Get-Date).AddHours(1).ToString('o') }
463 repo0 = @{ name = 'checkout'; defaultBranchRef = @{ target = @{ oid = '9999999999999999999999999999999999999999'; committedDate = (Get-Date).ToString('o') } } }
464 repo1 = @{ name = 'setup-node'; defaultBranchRef = @{ target = @{ oid = '8888888888888888888888888888888888888888'; committedDate = (Get-Date).ToString('o') } } }
465 }
466 }
467 }
468 else {
469 return [PSCustomObject]@{
470 data = [PSCustomObject]@{
471 rateLimit = [PSCustomObject]@{ remaining = 5000; cost = 1 }
472 commit0 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; committedDate = (Get-Date).AddDays(-60).ToString('o') } }
473 commit1 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c'; committedDate = (Get-Date).AddDays(-45).ToString('o') } }
474 }
475 }
476 }
477 }
478 elseif ($Uri -like '*/releases/latest') {
479 $repoName = ($Uri -split '/')[-3]
480 return @{
481 tag_name = switch ($repoName) {
482 'actionlint' { 'v1.7.10' }
483 'gitleaks' { 'v8.30.0' }
484 'cosign' { 'v3.0.5' }
485 default { 'v1.0.0' }
486 }
487 published_at = (Get-Date).AddMonths(-1).ToString('o')
488 }
489 }
490 return @{}
491 }
492 }
493
494 AfterAll {
495 # Restore original directory
496 Set-Location $script:OriginalLocation
497 }
498
499 Context 'Array coercion in main execution block' {
500 BeforeEach {
501 # Create workflow with SHA-pinned action
502 $workflowContent = @'
503name: Test
504on: push
505jobs:
506 test:
507 runs-on: ubuntu-latest
508 steps:
509 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
510'@
511 Set-Content -Path (Join-Path $script:WorkflowDir 'test.yml') -Value $workflowContent
512
513 # Change to test repo directory
514 Set-Location $script:TestRepo
515 }
516
517 AfterEach {
518 # Return to original location
519 Set-Location $script:OriginalLocation
520 }
521
522 It 'Executes array coercion when processing action repos' {
523 # This test executes the main script block which includes:
524 # - @($allActionRepos).Count checks (lines 532, 537)
525 # - @($Dependencies).Count checks throughout result formatting
526
527 $jsonPath = Join-Path $script:TestRepo 'logs' 'test-output.json'
528 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
529
530 # Validate JSON structure was created with array coercion
531 Test-Path $jsonPath | Should -BeTrue
532 $result = Get-Content $jsonPath | ConvertFrom-Json
533
534 # Verify array coercion created proper structure
535 $result.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
536 # JSON deserialization creates Int64 (long) not Int32
537 $result.TotalStaleItems | Should -BeOfType [long]
538 $result.PSObject.Properties.Name | Should -Contain 'Dependencies'
539 # Dependencies should be array (even if empty)
540 , $result.Dependencies | Should -BeOfType [System.Object[]]
541
542 # Verify mock API calls: 2 GraphQL batches (repos + commits) + 2 tool release checks
543 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*graphql*' } -Times 2 -Exactly
544 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*/releases/latest' } -Times 2 -Exactly
545 }
546
547 It 'Processes stale dependencies with array count operations' {
548 # Create multiple workflows to trigger grouping logic
549 for ($i = 1; $i -le 3; $i++) {
550 $workflowContent = @"
551name: Test$i
552on: push
553jobs:
554 test:
555 runs-on: ubuntu-latest
556 steps:
557 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
558 - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
559"@
560 Set-Content -Path (Join-Path $script:WorkflowDir "test$i.yml") -Value $workflowContent
561 }
562
563 # This executes lines that group and count dependencies:
564 # - @($Dependencies | Group-Object Type) (line 753)
565 # - @($type.Group | Where-Object...).Count in summary building
566
567 $jsonPath = Join-Path $script:TestRepo 'logs' 'grouped-test.json'
568 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
569
570 # Validate grouping and counting worked
571 Test-Path $jsonPath | Should -BeTrue
572 $result = Get-Content $jsonPath | ConvertFrom-Json
573
574 # Should have processed multiple action repos (array coercion on line 532)
575 $result.TotalStaleItems | Should -BeOfType [long]
576 $result.TotalStaleItems | Should -Be 2
577
578 # Log file should contain evidence of array counting
579 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
580 Test-Path $logPath | Should -BeTrue
581 $logContent = Get-Content $logPath -Raw
582 # Should log count of repos found (uses @($allActionRepos).Count)
583 $logContent | Should -Match 'unique repositories.*SHA-pinned actions'
584 }
585
586 It 'Handles tool staleness checking with array coercion' {
587 # This executes tool checking code:
588 # - @($toolResults).Count (line 895)
589 # - @($staleTools).Count (line 897-898)
590 # - @($errorTools).Count (line 921-922)
591
592 $jsonPath = Join-Path $script:TestRepo 'logs' 'tool-check.json'
593 & $scriptPath -OutputPath $jsonPath -OutputFormat 'json' *>&1 | Out-Null
594
595 # Validate tool processing used array coercion
596 Test-Path $jsonPath | Should -BeTrue
597 $result = Get-Content $jsonPath | ConvertFrom-Json
598
599 # Result should have TotalStaleItems even if zero (proves @($toolResults).Count worked)
600 $result.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
601
602 # Check log for tool checking evidence
603 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
604 $logContent = Get-Content $logPath -Raw
605 $logContent | Should -Match 'Checking tool staleness'
606
607 # Verify API calls: 2 GraphQL batches + 2 tool release checks
608 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*graphql*' } -Times 2 -Exactly
609 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*/releases/latest' } -Times 2 -Exactly
610 }
611
612 It 'Executes result formatting with array operations' {
613 # This triggers formatting code with array coercion:
614 # - @($Dependencies).Count in various output formats (lines 706, 721, 731, 742, 747, 752)
615 # - TotalStaleItems = @($Dependencies).Count (line 676)
616
617 $jsonPath = Join-Path $script:TestRepo 'logs' 'format-test.json'
618 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
619
620 # Validate JSON output format uses array coercion correctly
621 Test-Path $jsonPath | Should -BeTrue
622 $jsonResult = Get-Content $jsonPath | ConvertFrom-Json
623
624 # Verify required fields from array operations
625 $jsonResult.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
626 $jsonResult.PSObject.Properties.Name | Should -Contain 'Dependencies'
627 $jsonResult.PSObject.Properties.Name | Should -Contain 'Timestamp'
628
629 # TotalStaleItems should be numeric from @($Dependencies).Count
630 $jsonResult.TotalStaleItems | Should -BeOfType [long]
631 $jsonResult.TotalStaleItems | Should -Be 2
632
633 # Test Summary format exercises array coercion (@($Dependencies).Count)
634 # The key is that it executes the @($Dependencies).Count operations
635 $summaryOutput = & $scriptPath -OutputFormat 'Summary' 2>&1 | Out-String
636 $summaryOutput | Should -Match "(Total stale dependencies:|No stale dependencies detected)"
637 }
638 }
639
640 Context 'CI environment integration' {
641 BeforeEach {
642 # Save original environment
643 $script:OriginalGHA = $env:GITHUB_ACTIONS
644 $script:OriginalADO = $env:TF_BUILD
645 $script:OriginalGHOutput = $env:GITHUB_OUTPUT
646
647 # Create test workflow
648 $workflowContent = @'
649name: CI Test
650on: push
651jobs:
652 test:
653 runs-on: ubuntu-latest
654 steps:
655 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
656'@
657 Set-Content -Path (Join-Path $script:WorkflowDir 'ci.yml') -Value $workflowContent
658
659 # Change to test repo
660 Set-Location $script:TestRepo
661 }
662
663 AfterEach {
664 $env:GITHUB_ACTIONS = $script:OriginalGHA
665 $env:TF_BUILD = $script:OriginalADO
666 $env:GITHUB_OUTPUT = $script:OriginalGHOutput
667 Set-Location $script:OriginalLocation
668 }
669
670 It 'Executes GitHub Actions output formatting with array coercion' {
671 $env:GITHUB_ACTIONS = 'true'
672 $outputFile = Join-Path $script:TestRepo 'github-output.txt'
673 $env:GITHUB_OUTPUT = $outputFile
674
675 # This triggers GitHub Actions specific formatting (lines 706-715)
676 # which includes @($Dependencies).Count checks
677
678 & $scriptPath -OutputFormat 'github' *>&1 | Out-Null
679
680 # GitHub Actions format writes to GITHUB_OUTPUT, verify it was created
681 if ($outputFile -and (Test-Path $outputFile)) {
682 # Output file should have workflow command format
683 $content = Get-Content $outputFile -Raw
684 $content | Should -Not -BeNullOrEmpty
685 }
686
687 # Verify log shows proper array counting
688 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
689 $logContent = Get-Content $logPath -Raw
690 # Should mention "stale dependencies found" with count (uses @($Dependencies).Count)
691 $logContent | Should -Match 'Stale dependencies found: \d+'
692 }
693
694 It 'Executes Azure DevOps output formatting with array coercion' {
695 $env:TF_BUILD = 'true'
696
697 # This triggers ADO specific formatting (lines 721-729)
698 # which includes @($Dependencies).Count checks
699
700 & $scriptPath -OutputFormat 'azdo' *>&1 | Out-Null
701
702 # Azure DevOps format includes task.logissue commands
703 # Validates that @($Dependencies).Count was evaluated
704 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
705 $logContent = Get-Content $logPath -Raw
706 $logContent | Should -Match 'Stale dependencies found: \d+'
707 }
708
709 It 'Executes console output formatting with array coercion' {
710 # No CI environment - uses console output (lines 731-755)
711 # Includes @($Dependencies).Count and grouping operations
712
713 # Console format doesn't create output file unless -OutputPath specified
714 & $scriptPath -OutputFormat 'console' *>&1 | Out-Null
715
716 # Verify log contains array coercion evidence
717 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
718 Test-Path $logPath | Should -BeTrue
719 $logContent = Get-Content $logPath -Raw
720 # Should have processed and counted (uses @($Dependencies).Count)
721 $logContent | Should -Match 'SHA staleness monitoring completed'
722 $logContent | Should -Match 'Stale dependencies found: \d+'
723 }
724 }
725
726 Context 'Empty and edge case scenarios' {
727 BeforeEach {
728 Set-Location $script:TestRepo
729 }
730
731 AfterEach {
732 Set-Location $script:OriginalLocation
733 }
734
735 It 'Handles empty workflow directory with array coercion' {
736 # Remove all workflow files
737 Get-ChildItem $script:WorkflowDir -Filter "*.yml" | Remove-Item -Force
738
739 # This should execute array coercion on empty collections
740 # Testing @($allActionRepos).Count -eq 0 branch (line 532)
741
742 $jsonPath = Join-Path $script:TestRepo 'logs' 'empty-test.json'
743 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
744
745 # Validate empty array handling
746 Test-Path $jsonPath | Should -BeTrue
747 $result = Get-Content $jsonPath | ConvertFrom-Json
748
749 # Should show 0 items (proves @($allActionRepos).Count -eq 0 worked)
750 $result.TotalStaleItems | Should -Be 0
751
752 # Log should indicate no SHA-pinned actions found
753 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
754 $logContent = Get-Content $logPath -Raw
755 $logContent | Should -Match 'No SHA-pinned.*found|No stale dependencies'
756
757 # No actions found so no GraphQL calls, but tool staleness still runs
758 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*graphql*' } -Times 0 -Exactly
759 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*/releases/latest' } -Times 2 -Exactly
760 }
761
762 It 'Processes single stale dependency with array coercion' {
763 # Create single workflow
764 $singleWorkflow = @'
765name: Single
766on: push
767jobs:
768 test:
769 runs-on: ubuntu-latest
770 steps:
771 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
772'@
773 Set-Content -Path (Join-Path $script:WorkflowDir 'single.yml') -Value $singleWorkflow
774
775 # Script-scope safety-net mock -- REST fallback calls now route through SecurityHelpers.
776 Mock Invoke-RestMethod {
777 if ($Uri -like '*/repos/*/branches/*') {
778 return @{ commit = @{ sha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' } }
779 }
780 elseif ($Uri -like '*/repos/*/commits/*') {
781 return @{ commit = @{ author = @{ date = (Get-Date).AddMonths(-6).ToString('o') } } }
782 }
783 elseif ($Uri -like '*/repos/*') {
784 return @{ default_branch = 'main' }
785 }
786 return @{}
787 }
788
789 # Module-scope mock for Invoke-RestMethod calls inside SecurityHelpers.
790 # Uses aliased GraphQL response structure matching the script's batch queries.
791 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
792 if ($Uri -like '*graphql*') {
793 if ($Body -match 'viewer') {
794 return @{
795 data = @{
796 viewer = @{ login = 'testuser' }
797 rateLimit = @{ remaining = 5000; limit = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
798 }
799 }
800 }
801 elseif ($Body -match 'defaultBranchRef') {
802 # Single repo -- latest SHA differs from pinned SHA to trigger stale detection
803 return @{
804 data = @{
805 rateLimit = @{ remaining = 5000; limit = 5000; used = 1; resetAt = (Get-Date).AddHours(1).ToString('o') }
806 repo0 = @{ name = 'checkout'; defaultBranchRef = @{ target = @{ oid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; committedDate = (Get-Date).ToString('o') } } }
807 }
808 }
809 }
810 else {
811 # Commit batch -- PSCustomObject required for PSObject.Properties iteration
812 return [PSCustomObject]@{
813 data = [PSCustomObject]@{
814 rateLimit = [PSCustomObject]@{ remaining = 5000; cost = 1 }
815 commit0 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; committedDate = (Get-Date).AddMonths(-6).ToString('o') } }
816 }
817 }
818 }
819 }
820 return @{}
821 }
822
823 # Single item return should be coerced to array, also tests stale detection
824 $jsonPath = Join-Path $script:TestRepo 'logs' 'single-test.json'
825 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
826
827 # Validate single item is properly handled as array
828 Test-Path $jsonPath | Should -BeTrue
829 $result = Get-Content $jsonPath | ConvertFrom-Json
830
831 # Single dependency should still produce numeric count (not $null)
832 $result.TotalStaleItems | Should -BeOfType [long]
833 $result.TotalStaleItems | Should -Be 1
834 # Dependencies array should exist
835 $result.PSObject.Properties.Name | Should -Contain 'Dependencies'
836 $result.Dependencies | Should -Not -BeNullOrEmpty
837 $result.Dependencies[0].PSObject.Properties.Name | Should -Contain 'Type'
838 }
839 }
840}
841
842Describe 'Get-BulkGitHubActionsStaleness' -Tag 'Unit' {
843 BeforeAll {
844 Save-CIEnvironment
845 }
846 AfterAll {
847 Restore-CIEnvironment
848 }
849
850 Context 'Token resolution' {
851 BeforeEach {
852 $env:GITHUB_TOKEN = ''
853 $env:SYSTEM_ACCESSTOKEN = ''
854 $env:GH_TOKEN = ''
855 $env:BUILD_REPOSITORY_PROVIDER = ''
856 }
857
858 It 'Uses GITHUB_TOKEN when available' {
859 $env:GITHUB_TOKEN = 'ghp_test_token_123'
860 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
861 Mock Invoke-GitHubAPIWithRetry {
862 return @{
863 data = @{
864 rateLimit = @{ remaining = 5000 }
865 repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } }
866 }
867 }
868 }
869
870 $sha = 'aaaa' * 10
871 $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
872 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
873 }
874
875 Should -Invoke Test-GitHubToken -Times 1
876 }
877
878 It 'Falls back to GH_TOKEN when GITHUB_TOKEN is empty' {
879 $env:GH_TOKEN = 'ghp_fallback_token'
880 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
881 Mock Invoke-GitHubAPIWithRetry {
882 return @{
883 data = @{
884 rateLimit = @{ remaining = 5000 }
885 repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } }
886 }
887 }
888 }
889
890 $sha = 'aaaa' * 10
891 $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
892 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
893 }
894
895 Should -Invoke Test-GitHubToken -Times 1
896 }
897
898 It 'Uses SYSTEM_ACCESSTOKEN for GitHub-hosted ADO repos' {
899 $env:SYSTEM_ACCESSTOKEN = 'ado_token'
900 $env:BUILD_REPOSITORY_PROVIDER = 'GitHub'
901 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
902 Mock Invoke-GitHubAPIWithRetry {
903 return @{
904 data = @{
905 rateLimit = @{ remaining = 5000 }
906 repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } }
907 }
908 }
909 }
910
911 $sha = 'aaaa' * 10
912 $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
913 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
914 }
915
916 Should -Invoke Test-GitHubToken -Times 1
917 }
918 }
919
920 Context 'GraphQL batch processing' {
921 BeforeEach {
922 $env:GITHUB_TOKEN = 'ghp_test_token'
923 }
924
925 It 'Returns stale result when SHA differs and age exceeds threshold' {
926 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
927
928 $latestSHA = 'bbbb' * 10
929 $currentSHA = 'aaaa' * 10
930 $oldDate = (Get-Date).AddDays(-60).ToString('o')
931 $newDate = (Get-Date).ToString('o')
932
933 # Default branch query
934 Mock Invoke-GitHubAPIWithRetry {
935 return @{
936 data = @{
937 rateLimit = @{ remaining = 5000 }
938 repo0 = @{ defaultBranchRef = @{ target = @{ oid = $latestSHA; committedDate = $newDate } } }
939 }
940 }
941 } -ParameterFilter { $Body -match 'defaultBranchRef' }
942
943 # Commit query - use [PSCustomObject] so PSObject.Properties iteration works
944 Mock Invoke-GitHubAPIWithRetry {
945 return [PSCustomObject]@{
946 data = [PSCustomObject]@{
947 rateLimit = [PSCustomObject]@{ remaining = 5000 }
948 commit0 = [PSCustomObject]@{
949 object = [PSCustomObject]@{
950 oid = $currentSHA
951 committedDate = $oldDate
952 }
953 }
954 }
955 }
956 } -ParameterFilter { $Body -match 'commit0' }
957
958 $result = Get-BulkGitHubActionsStaleness -ActionRepos @('actions/checkout') -ShaToActionMap @{
959 "actions/checkout@$currentSHA" = @{ Repo = 'actions/checkout'; SHA = $currentSHA; File = 'ci.yml' }
960 }
961
962 $result | Should -Not -BeNullOrEmpty
963 @($result).Count | Should -BeGreaterOrEqual 1
964 Should -Invoke Invoke-GitHubAPIWithRetry -Times 2
965 }
966 }
967
968 Context 'Invalid token' {
969 BeforeEach {
970 $env:GITHUB_TOKEN = ''
971 $env:SYSTEM_ACCESSTOKEN = ''
972 $env:GH_TOKEN = ''
973 $env:BUILD_REPOSITORY_PROVIDER = ''
974 }
975
976 It 'Returns empty when no valid token is available' {
977 Mock Test-GitHubToken { return @{ Valid = $false; Authenticated = $false; Message = 'No token' } }
978 Mock Write-SecurityLog { }
979 Mock Invoke-GitHubAPIWithRetry {
980 return @{
981 data = @{
982 rateLimit = @{ remaining = 60 }
983 repo0 = @{ defaultBranchRef = $null }
984 }
985 }
986 }
987
988 $sha = 'aaaa' * 10
989 $result = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
990 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
991 }
992
993 @($result).Count | Should -Be 0
994 Should -Invoke Test-GitHubToken -Times 1
995 }
996 }
997}
998
999Describe 'Test-GitHubActionsForStaleness' -Tag 'Unit' {
1000 BeforeAll {
1001 Save-CIEnvironment
1002 $script:TestWorkflows = Join-Path $TestDrive '.github' 'workflows'
1003 New-Item -ItemType Directory -Path $script:TestWorkflows -Force | Out-Null
1004 }
1005 AfterAll {
1006 Restore-CIEnvironment
1007 }
1008
1009 Context 'Workflow scanning' {
1010 It 'Returns empty when no SHA-pinned actions found' {
1011 $ymlContent = @'
1012name: test
1013on: push
1014jobs:
1015 build:
1016 runs-on: ubuntu-latest
1017 steps:
1018 - uses: actions/checkout@v4
1019'@
1020 Set-Content (Join-Path $script:TestWorkflows 'no-sha.yml') -Value $ymlContent
1021
1022 Push-Location $TestDrive
1023 try {
1024 Mock Write-SecurityLog { }
1025 Mock Get-BulkGitHubActionsStaleness { return @() }
1026
1027 $null = Test-GitHubActionsForStaleness
1028
1029 # No SHA-pinned actions found = early return
1030 Should -Not -Invoke Get-BulkGitHubActionsStaleness
1031 }
1032 finally {
1033 Pop-Location
1034 }
1035 }
1036
1037 It 'Detects SHA-pinned actions in workflow files' {
1038 $sha = 'a' * 40
1039 $ymlContent = @"
1040name: test
1041on: push
1042jobs:
1043 build:
1044 runs-on: ubuntu-latest
1045 steps:
1046 - uses: actions/checkout@$sha
1047"@
1048 Set-Content (Join-Path $script:TestWorkflows 'pinned.yml') -Value $ymlContent
1049
1050 Push-Location $TestDrive
1051 try {
1052 Mock Write-SecurityLog { }
1053 Mock Get-BulkGitHubActionsStaleness { return @() }
1054
1055 $null = Test-GitHubActionsForStaleness
1056
1057 Should -Invoke Get-BulkGitHubActionsStaleness -Times 1
1058 }
1059 finally {
1060 Pop-Location
1061 }
1062 }
1063 }
1064
1065 Context 'No workflow directory' {
1066 It 'Returns empty when .github/workflows does not exist' {
1067 $emptyDir = Join-Path $TestDrive 'empty-project'
1068 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
1069
1070 Push-Location $emptyDir
1071 try {
1072 Mock Write-SecurityLog { }
1073
1074 $result = Test-GitHubActionsForStaleness
1075 @($result).Count | Should -Be 0
1076 }
1077 finally {
1078 Pop-Location
1079 }
1080 }
1081 }
1082}
1083
1084Describe 'Write-SecurityOutput' -Tag 'Unit' {
1085 Context 'JSON output format' {
1086 It 'Creates output file with correct structure' {
1087 $jsonPath = Join-Path $TestDrive 'output.json'
1088 $deps = @(
1089 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low' }
1090 )
1091
1092 Write-SecurityOutput -Dependencies $deps -OutputFormat 'json' -OutputPath $jsonPath
1093
1094 Test-Path $jsonPath | Should -BeTrue
1095 $content = Get-Content $jsonPath | ConvertFrom-Json
1096 $content.TotalStaleItems | Should -Be 1
1097 }
1098 }
1099
1100 Context 'Console output format' {
1101 It 'Writes formatted output via Write-SecurityLog' {
1102 Mock Write-SecurityLog { }
1103
1104 $deps = @(
1105 @{ Type = 'GitHubAction'; ActionRepo = 'actions/checkout'; DaysOld = 45; Severity = 'Low'; File = 'ci.yml' }
1106 )
1107
1108 Write-SecurityOutput -Dependencies $deps -OutputFormat 'console'
1109
1110 Should -Invoke Write-SecurityLog -Times 1
1111 }
1112 }
1113
1114 Context 'Summary output format' {
1115 It 'Groups dependencies by type' {
1116 Mock Write-Output { }
1117
1118 $deps = @(
1119 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low' }
1120 @{ Type = 'Tool'; Name = 'node'; DaysOld = 90; Severity = 'High' }
1121 )
1122
1123 Write-SecurityOutput -Dependencies $deps -OutputFormat 'Summary'
1124
1125 Should -Invoke Write-Output -Times 1
1126 }
1127 }
1128
1129 Context 'GitHub output format with stale dependencies' {
1130 BeforeAll {
1131 # Write-SecurityOutput receives pre-filtered stale items; all entries are stale by definition
1132 $script:githubDeps = @(
1133 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low'; File = 'ci.yml'; Message = 'GitHub Action is 45 days old' }
1134 @{ Type = 'GitHubAction'; Name = 'actions/setup-node'; DaysOld = 90; Severity = 'High'; File = 'build.yml'; Message = 'GitHub Action is 90 days old' }
1135 )
1136 }
1137
1138 BeforeEach {
1139 Mock Write-CIAnnotation { }
1140 Mock Write-CIStepSummary { }
1141 Write-SecurityOutput -Dependencies $script:githubDeps -OutputFormat 'github'
1142 }
1143
1144 It 'Calls Write-CIAnnotation for each dependency with Warning level' {
1145 Should -Invoke Write-CIAnnotation -Times 2 -ParameterFilter { $Level -eq 'Warning' }
1146 }
1147
1148 It 'Calls Write-CIAnnotation aggregate with Error level' {
1149 Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { $Level -eq 'Error' }
1150 }
1151
1152 It 'Calls Write-CIAnnotation total of 3 times (2 per-item + 1 aggregate)' {
1153 Should -Invoke Write-CIAnnotation -Times 3 -Exactly
1154 }
1155
1156 It 'Calls Write-CIStepSummary exactly once' {
1157 Should -Invoke Write-CIStepSummary -Times 1 -Exactly
1158 }
1159
1160 It 'Passes markdown containing the summary table header' {
1161 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1162 $Content -match '\| Dependency \| SHA Age \(days\) \| Threshold \(days\) \| Status \|'
1163 }
1164 }
1165
1166 It 'Includes dependency names in summary content' {
1167 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1168 $Content -match 'actions/checkout' -and $Content -match 'actions/setup-node'
1169 }
1170 }
1171
1172 It 'Shows stale status for all dependencies' {
1173 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1174 $Content -match 'Stale'
1175 }
1176 }
1177
1178 It 'Includes totals in summary content' {
1179 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1180 $Content -match 'Found:.+2' -and $Content -match 'Stale:.+2'
1181 }
1182 }
1183 }
1184
1185 Context 'GitHub output format with no stale dependencies' {
1186 BeforeEach {
1187 Mock Write-CIAnnotation { }
1188 Mock Write-CIStepSummary { }
1189 Write-SecurityOutput -Dependencies @() -OutputFormat 'github'
1190 }
1191
1192 It 'Calls Write-CIAnnotation with Notice level for no stale deps' {
1193 Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { $Level -eq 'Notice' }
1194 }
1195
1196 It 'Calls Write-CIAnnotation exactly once' {
1197 Should -Invoke Write-CIAnnotation -Times 1 -Exactly
1198 }
1199
1200 It 'Calls Write-CIStepSummary exactly once' {
1201 Should -Invoke Write-CIStepSummary -Times 1 -Exactly
1202 }
1203
1204 It 'Passes all-clear summary when no dependencies' {
1205 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1206 $Content -match 'All Clear' -and $Content -match 'No stale dependencies detected'
1207 }
1208 }
1209 }
1210}
1211
1212Describe 'Invoke-SHAStalenessCheck' -Tag 'Unit' {
1213 BeforeAll {
1214 Save-CIEnvironment
1215 }
1216 AfterAll {
1217 Restore-CIEnvironment
1218 }
1219
1220 Context 'Log directory creation' {
1221 It 'Creates log directory when it does not exist' {
1222 $logPath = Join-Path $TestDrive 'staleness-logs' 'test.log'
1223 $env:GITHUB_TOKEN = 'ghp_test'
1224
1225 Mock Test-GitHubActionsForStaleness { return @() }
1226 Mock Get-ToolStaleness { }
1227 Mock Write-SecurityOutput { }
1228 Mock New-Item { } -ParameterFilter { $ItemType -eq 'Directory' }
1229 Mock Write-SecurityLog { }
1230
1231 Invoke-SHAStalenessCheck -OutputFormat 'console' -LogPath $logPath
1232
1233 Should -Invoke New-Item -Times 1
1234 }
1235 }
1236
1237 Context 'FailOnStale behavior' {
1238 It 'Throws when stale dependencies are detected and FailOnStale is set' {
1239 $env:GITHUB_TOKEN = 'ghp_test'
1240 Mock Write-SecurityLog { }
1241 Mock New-Item { }
1242 Mock Test-GitHubActionsForStaleness {
1243 $script:StaleDependencies = @(
1244 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45 }
1245 )
1246 }
1247 Mock Get-ToolStaleness { }
1248 Mock Get-PSModuleStaleness { }
1249 Mock Write-SecurityOutput { }
1250
1251 { Invoke-SHAStalenessCheck -OutputFormat 'console' -FailOnStale } |
1252 Should -Throw '*Stale dependencies detected*'
1253 }
1254
1255 It 'Does not throw when no stale dependencies and FailOnStale is set' {
1256 $env:GITHUB_TOKEN = 'ghp_test'
1257 Mock Write-SecurityLog { }
1258 Mock New-Item { }
1259 Mock Test-GitHubActionsForStaleness {
1260 $script:StaleDependencies = @()
1261 }
1262 Mock Get-ToolStaleness { }
1263 Mock Get-PSModuleStaleness { }
1264 Mock Write-SecurityOutput { }
1265
1266 { Invoke-SHAStalenessCheck -OutputFormat 'console' -FailOnStale } |
1267 Should -Not -Throw
1268 }
1269 }
1270}
1271