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-SHAStaleness.Tests.ps1

649lines · 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 AST function extraction to avoid running main execution block.
12#>
13
14BeforeAll {
15 $scriptPath = Join-Path $PSScriptRoot '../../security/Test-SHAStaleness.ps1'
16 $script:OriginalSkipMain = $env:HVE_SKIP_MAIN
17 $env:HVE_SKIP_MAIN = '1'
18 . $scriptPath
19
20 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
21 Import-Module $mockPath -Force
22
23 # Save environment before tests
24 Save-CIEnvironment
25
26 # Fixture paths
27 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Security'
28}
29
30AfterAll {
31 # Restore environment after tests
32 Restore-CIEnvironment
33 $env:HVE_SKIP_MAIN = $script:OriginalSkipMain
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 60 when no token provided' {
58 $result = Test-GitHubToken -Token ''
59 $result.RateLimit | Should -Be 60
60 }
61 }
62
63 Context 'Invalid token' {
64 BeforeEach {
65 Mock Invoke-RestMethod {
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 {
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 {
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 'Rate limiting' {
128 It 'Throws on non-rate-limit errors' {
129 Mock Invoke-RestMethod {
130 throw [System.Exception]::new('Network error')
131 }
132
133 $headers = @{ 'Authorization' = 'Bearer test' }
134 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers $headers -Body '{}' } | Should -Throw
135 }
136 }
137}
138
139Describe 'Write-SecurityLog' -Tag 'Unit' {
140 Context 'Log output' {
141 It 'Does not throw for Info level' {
142 { Write-SecurityLog -Message 'Test message' -Level Info } | Should -Not -Throw
143 }
144
145 It 'Does not throw for Warning level' {
146 { Write-SecurityLog -Message 'Warning message' -Level Warning } | Should -Not -Throw
147 }
148
149 It 'Does not throw for Error level' {
150 { Write-SecurityLog -Message 'Error message' -Level Error } | Should -Not -Throw
151 }
152
153 It 'Does not throw for Success level' {
154 { Write-SecurityLog -Message 'Success message' -Level Success } | Should -Not -Throw
155 }
156 }
157}
158
159Describe 'Compare-ToolVersion' -Tag 'Unit' {
160 Context 'Semantic version comparison' {
161 It 'Returns true when latest is newer major version' {
162 Compare-ToolVersion -Current '1.0.0' -Latest '2.0.0' | Should -BeTrue
163 }
164
165 It 'Returns true when latest is newer minor version' {
166 Compare-ToolVersion -Current '1.0.0' -Latest '1.1.0' | Should -BeTrue
167 }
168
169 It 'Returns true when latest is newer patch version' {
170 Compare-ToolVersion -Current '1.0.0' -Latest '1.0.1' | Should -BeTrue
171 }
172
173 It 'Returns false when current equals latest' {
174 Compare-ToolVersion -Current '1.0.0' -Latest '1.0.0' | Should -BeFalse
175 }
176
177 It 'Returns false when current is newer than latest' {
178 Compare-ToolVersion -Current '2.0.0' -Latest '1.0.0' | Should -BeFalse
179 }
180
181 It 'Handles major version differences correctly' {
182 Compare-ToolVersion -Current '7.0.0' -Latest '8.0.0' | Should -BeTrue
183 }
184
185 It 'Handles minor version differences correctly' {
186 Compare-ToolVersion -Current '8.17.0' -Latest '8.18.0' | Should -BeTrue
187 }
188
189 It 'Handles patch version differences correctly' {
190 Compare-ToolVersion -Current '8.18.1' -Latest '8.18.2' | Should -BeTrue
191 }
192 }
193
194 Context 'Version with v prefix' {
195 It 'Handles v-prefixed versions' {
196 Compare-ToolVersion -Current 'v1.0.0' -Latest 'v2.0.0' | Should -BeTrue
197 }
198
199 It 'Handles mixed v-prefix versions' {
200 Compare-ToolVersion -Current '1.0.0' -Latest 'v2.0.0' | Should -BeTrue
201 }
202
203 It 'Returns false for equal v-prefixed versions' {
204 Compare-ToolVersion -Current 'v1.0.0' -Latest 'v1.0.0' | Should -BeFalse
205 }
206 }
207
208 Context 'Pre-release versions' {
209 It 'Strips pre-release metadata for comparison' {
210 Compare-ToolVersion -Current '1.0.0-alpha' -Latest '1.0.0' | Should -BeFalse
211 }
212
213 It 'Handles build metadata' {
214 Compare-ToolVersion -Current '1.0.0+build123' -Latest '2.0.0' | Should -BeTrue
215 }
216 }
217}
218
219Describe 'Get-ToolStaleness' -Tag 'Integration', 'RequiresNetwork' {
220 Context 'With mock manifest' {
221 BeforeEach {
222 # Create a temporary manifest file
223 $script:TempManifest = Join-Path $TestDrive 'tool-checksums.json'
224 $manifestContent = @{
225 tools = @(
226 @{
227 name = 'test-tool'
228 repo = 'test-org/test-repo'
229 version = '1.0.0'
230 sha256 = 'abc123'
231 notes = 'Test tool'
232 }
233 )
234 } | ConvertTo-Json -Depth 10
235 Set-Content -Path $script:TempManifest -Value $manifestContent
236 }
237
238 It 'Returns results array' -Skip:$true {
239 # Skip by default - requires actual GitHub API access
240 $result = Get-ToolStaleness -ManifestPath $script:TempManifest
241 $result | Should -BeOfType [System.Object[]]
242 }
243 }
244
245 Context 'Missing manifest' {
246 It 'Handles missing manifest gracefully' {
247 $result = Get-ToolStaleness -ManifestPath 'TestDrive:/nonexistent/manifest.json'
248 $result | Should -BeNullOrEmpty
249 }
250 }
251}
252
253Describe 'Main Script Execution' {
254 BeforeAll {
255 # Create test repo structure (script expects .github/workflows from current directory)
256 $script:TestRepo = Join-Path $TestDrive 'test-repo'
257 $script:WorkflowDir = Join-Path $script:TestRepo '.github' 'workflows'
258 New-Item -ItemType Directory -Path $script:WorkflowDir -Force | Out-Null
259
260 # Create logs directory
261 $logsDir = Join-Path $script:TestRepo 'logs'
262 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
263
264 # Create test manifest in scripts/security location
265 $manifestDir = Join-Path $script:TestRepo 'scripts' 'security'
266 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
267 $script:ManifestPath = Join-Path $manifestDir 'tool-checksums.json'
268 @{
269 tools = @(
270 @{
271 name = 'pwsh'
272 repo = 'PowerShell/PowerShell'
273 version = '7.4.0'
274 sha256 = 'test-sha'
275 notes = 'PowerShell'
276 }
277 )
278 } | ConvertTo-Json -Depth 10 | Set-Content -Path $script:ManifestPath
279
280 # Save current directory
281 $script:OriginalLocation = Get-Location
282
283 # Mock GitHub API at Describe scope to affect all child script invocations
284 Mock Invoke-RestMethod {
285 if ($Uri -like '*graphql*') {
286 # GraphQL API for checking GitHub Actions
287 return @{
288 data = @{
289 rateLimit = @{ remaining = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
290 repository = @{
291 refs = @{
292 nodes = @(
293 @{
294 name = 'v5'
295 target = @{
296 oid = '9999999999999999999999999999999999999999'
297 committedDate = (Get-Date).AddMonths(-2).ToString('o')
298 }
299 }
300 )
301 }
302 }
303 }
304 }
305 }
306 elseif ($Uri -like '*/releases/latest') {
307 # REST API for checking tool releases
308 $repoName = ($Uri -split '/')[-3]
309 return @{
310 tag_name = switch ($repoName) {
311 'actionlint' { 'v1.7.10' }
312 'gitleaks' { 'v8.30.0' }
313 default { 'v1.0.0' }
314 }
315 published_at = (Get-Date).AddMonths(-1).ToString('o')
316 }
317 }
318 return @{}
319 }
320 }
321
322 AfterAll {
323 # Restore original directory
324 Set-Location $script:OriginalLocation
325 }
326
327 Context 'Array coercion in main execution block' {
328 BeforeEach {
329 # Clear HVE_SKIP_MAIN so script actually runs main block
330 $env:HVE_SKIP_MAIN = $null
331
332 # Create workflow with SHA-pinned action
333 $workflowContent = @'
334name: Test
335on: push
336jobs:
337 test:
338 runs-on: ubuntu-latest
339 steps:
340 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
341'@
342 Set-Content -Path (Join-Path $script:WorkflowDir 'test.yml') -Value $workflowContent
343
344 # Change to test repo directory
345 Set-Location $script:TestRepo
346 }
347
348 AfterEach {
349 # Restore HVE_SKIP_MAIN
350 $env:HVE_SKIP_MAIN = '1'
351 # Return to original location
352 Set-Location $script:OriginalLocation
353 }
354
355 It 'Executes array coercion when processing action repos' {
356 # This test executes the main script block which includes:
357 # - @($allActionRepos).Count checks (lines 532, 537)
358 # - @($Dependencies).Count checks throughout result formatting
359
360 $jsonPath = Join-Path $script:TestRepo 'logs' 'test-output.json'
361 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
362
363 # Validate JSON structure was created with array coercion
364 Test-Path $jsonPath | Should -BeTrue
365 $result = Get-Content $jsonPath | ConvertFrom-Json
366
367 # Verify array coercion created proper structure
368 $result.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
369 # JSON deserialization creates Int64 (long) not Int32
370 $result.TotalStaleItems | Should -BeOfType [long]
371 $result.PSObject.Properties.Name | Should -Contain 'Dependencies'
372 # Dependencies should be array (even if empty)
373 , $result.Dependencies | Should -BeOfType [System.Object[]]
374 }
375
376 It 'Processes stale dependencies with array count operations' {
377 # Create multiple workflows to trigger grouping logic
378 for ($i = 1; $i -le 3; $i++) {
379 $workflowContent = @"
380name: Test$i
381on: push
382jobs:
383 test:
384 runs-on: ubuntu-latest
385 steps:
386 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
387 - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
388"@
389 Set-Content -Path (Join-Path $script:WorkflowDir "test$i.yml") -Value $workflowContent
390 }
391
392 # This executes lines that group and count dependencies:
393 # - @($Dependencies | Group-Object Type) (line 753)
394 # - @($type.Group | Where-Object...).Count in summary building
395
396 $jsonPath = Join-Path $script:TestRepo 'logs' 'grouped-test.json'
397 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
398
399 # Validate grouping and counting worked
400 Test-Path $jsonPath | Should -BeTrue
401 $result = Get-Content $jsonPath | ConvertFrom-Json
402
403 # Should have processed multiple action repos (array coercion on line 532)
404 $result.TotalStaleItems | Should -BeOfType [long]
405 $result.TotalStaleItems | Should -BeGreaterOrEqual 0
406
407 # Log file should contain evidence of array counting
408 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
409 Test-Path $logPath | Should -BeTrue
410 $logContent = Get-Content $logPath -Raw
411 # Should log count of repos found (uses @($allActionRepos).Count)
412 $logContent | Should -Match 'unique repositories.*SHA-pinned actions'
413 }
414
415 It 'Handles tool staleness checking with array coercion' {
416 # This executes tool checking code:
417 # - @($toolResults).Count (line 895)
418 # - @($staleTools).Count (line 897-898)
419 # - @($errorTools).Count (line 921-922)
420
421 $jsonPath = Join-Path $script:TestRepo 'logs' 'tool-check.json'
422 & $scriptPath -OutputPath $jsonPath -OutputFormat 'json' *>&1 | Out-Null
423
424 # Validate tool processing used array coercion
425 Test-Path $jsonPath | Should -BeTrue
426 $result = Get-Content $jsonPath | ConvertFrom-Json
427
428 # Result should have TotalStaleItems even if zero (proves @($toolResults).Count worked)
429 $result.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
430
431 # Check log for tool checking evidence
432 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
433 $logContent = Get-Content $logPath -Raw
434 $logContent | Should -Match 'Checking tool staleness'
435 }
436
437 It 'Executes result formatting with array operations' {
438 # This triggers formatting code with array coercion:
439 # - @($Dependencies).Count in various output formats (lines 706, 721, 731, 742, 747, 752)
440 # - TotalStaleItems = @($Dependencies).Count (line 676)
441
442 $jsonPath = Join-Path $script:TestRepo 'logs' 'format-test.json'
443 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
444
445 # Validate JSON output format uses array coercion correctly
446 Test-Path $jsonPath | Should -BeTrue
447 $jsonResult = Get-Content $jsonPath | ConvertFrom-Json
448
449 # Verify required fields from array operations
450 $jsonResult.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
451 $jsonResult.PSObject.Properties.Name | Should -Contain 'Dependencies'
452 $jsonResult.PSObject.Properties.Name | Should -Contain 'Timestamp'
453
454 # TotalStaleItems should be numeric from @($Dependencies).Count
455 $jsonResult.TotalStaleItems | Should -BeOfType [long]
456 $jsonResult.TotalStaleItems | Should -BeGreaterOrEqual 0
457
458 # Test Summary format exercises array coercion (@($Dependencies).Count)
459 # The key is that it executes the @($Dependencies).Count operations
460 $summaryOutput = & $scriptPath -OutputFormat 'Summary' 2>&1 | Out-String
461 $summaryOutput | Should -Match "(Total stale dependencies:|No stale dependencies detected)"
462 }
463 }
464
465 Context 'CI environment integration' {
466 BeforeEach {
467 # Clear HVE_SKIP_MAIN so script actually runs main block
468 $env:HVE_SKIP_MAIN = $null
469
470 # Save original environment
471 $script:OriginalGHA = $env:GITHUB_ACTIONS
472 $script:OriginalADO = $env:TF_BUILD
473
474 # Create test workflow
475 $workflowContent = @'
476name: CI Test
477on: push
478jobs:
479 test:
480 runs-on: ubuntu-latest
481 steps:
482 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
483'@
484 Set-Content -Path (Join-Path $script:WorkflowDir 'ci.yml') -Value $workflowContent
485
486 # Change to test repo
487 Set-Location $script:TestRepo
488 }
489
490 AfterEach {
491 # Restore HVE_SKIP_MAIN
492 $env:HVE_SKIP_MAIN = '1'
493 $env:GITHUB_ACTIONS = $script:OriginalGHA
494 $env:TF_BUILD = $script:OriginalADO
495 Set-Location $script:OriginalLocation
496 }
497
498 It 'Executes GitHub Actions output formatting with array coercion' {
499 $env:GITHUB_ACTIONS = 'true'
500 $outputFile = Join-Path $script:TestRepo 'github-output.txt'
501 $env:GITHUB_OUTPUT = $outputFile
502
503 # This triggers GitHub Actions specific formatting (lines 706-715)
504 # which includes @($Dependencies).Count checks
505
506 & $scriptPath -OutputFormat 'github' *>&1 | Out-Null
507
508 # GitHub Actions format writes to GITHUB_OUTPUT, verify it was created
509 if ($outputFile -and (Test-Path $outputFile)) {
510 # Output file should have workflow command format
511 $content = Get-Content $outputFile -Raw
512 $content | Should -Not -BeNullOrEmpty
513 }
514
515 # Verify log shows proper array counting
516 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
517 $logContent = Get-Content $logPath -Raw
518 # Should mention "stale dependencies found" with count (uses @($Dependencies).Count)
519 $logContent | Should -Match 'Stale dependencies found: \d+'
520 }
521
522 It 'Executes Azure DevOps output formatting with array coercion' {
523 $env:TF_BUILD = 'true'
524
525 # This triggers ADO specific formatting (lines 721-729)
526 # which includes @($Dependencies).Count checks
527
528 & $scriptPath -OutputFormat 'azdo' *>&1 | Out-Null
529
530 # Azure DevOps format includes task.logissue commands
531 # Validates that @($Dependencies).Count was evaluated
532 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
533 $logContent = Get-Content $logPath -Raw
534 $logContent | Should -Match 'Stale dependencies found: \d+'
535 }
536
537 It 'Executes console output formatting with array coercion' {
538 # No CI environment - uses console output (lines 731-755)
539 # Includes @($Dependencies).Count and grouping operations
540
541 # Console format doesn't create output file unless -OutputPath specified
542 & $scriptPath -OutputFormat 'console' *>&1 | Out-Null
543
544 # Verify log contains array coercion evidence
545 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
546 Test-Path $logPath | Should -BeTrue
547 $logContent = Get-Content $logPath -Raw
548 # Should have processed and counted (uses @($Dependencies).Count)
549 $logContent | Should -Match 'SHA staleness monitoring completed'
550 $logContent | Should -Match 'Stale dependencies found: \d+'
551 }
552 }
553
554 Context 'Empty and edge case scenarios' {
555 BeforeEach {
556 # Clear HVE_SKIP_MAIN so script actually runs main block
557 $env:HVE_SKIP_MAIN = $null
558 Set-Location $script:TestRepo
559 }
560
561 AfterEach {
562 # Restore HVE_SKIP_MAIN
563 $env:HVE_SKIP_MAIN = '1'
564 Set-Location $script:OriginalLocation
565 }
566
567 It 'Handles empty workflow directory with array coercion' {
568 # Remove all workflow files
569 Get-ChildItem $script:WorkflowDir -Filter "*.yml" | Remove-Item -Force
570
571 # This should execute array coercion on empty collections
572 # Testing @($allActionRepos).Count -eq 0 branch (line 532)
573
574 $jsonPath = Join-Path $script:TestRepo 'logs' 'empty-test.json'
575 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
576
577 # Validate empty array handling
578 Test-Path $jsonPath | Should -BeTrue
579 $result = Get-Content $jsonPath | ConvertFrom-Json
580
581 # Should show 0 items (proves @($allActionRepos).Count -eq 0 worked)
582 $result.TotalStaleItems | Should -Be 0
583
584 # Log should indicate no SHA-pinned actions found
585 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
586 $logContent = Get-Content $logPath -Raw
587 $logContent | Should -Match 'No SHA-pinned.*found|No stale dependencies'
588 }
589
590 It 'Processes single stale dependency with array coercion' {
591 # Create single workflow
592 $singleWorkflow = @'
593name: Single
594on: push
595jobs:
596 test:
597 runs-on: ubuntu-latest
598 steps:
599 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
600'@
601 Set-Content -Path (Join-Path $script:WorkflowDir 'single.yml') -Value $singleWorkflow
602
603 # Mock to return stale dependency (old SHA)
604 Mock Invoke-RestMethod {
605 if ($Uri -like '*graphql*') {
606 return @{
607 data = @{
608 rateLimit = @{ remaining = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
609 repository = @{
610 refs = @{
611 nodes = @(
612 @{
613 name = 'v5'
614 target = @{
615 oid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
616 committedDate = (Get-Date).AddMonths(-6).ToString('o')
617 }
618 }
619 )
620 }
621 }
622 }
623 }
624 }
625 return @{}
626 } -ModuleName $null
627
628 # Single item return should be coerced to array, also tests stale detection
629 $jsonPath = Join-Path $script:TestRepo 'logs' 'single-test.json'
630 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
631
632 # Validate single item is properly handled as array
633 Test-Path $jsonPath | Should -BeTrue
634 $result = Get-Content $jsonPath | ConvertFrom-Json
635
636 # Single dependency should still produce numeric count (not $null)
637 $result.TotalStaleItems | Should -BeOfType [long]
638 $result.TotalStaleItems | Should -BeGreaterOrEqual 0
639 # Dependencies array should exist
640 $result.PSObject.Properties.Name | Should -Contain 'Dependencies'
641 # If we have dependencies, validate structure
642 if ($result.TotalStaleItems -gt 0) {
643 $result.Dependencies | Should -Not -BeNullOrEmpty
644 # First item should have required properties
645 $result.Dependencies[0].PSObject.Properties.Name | Should -Contain 'Type'
646 }
647 }
648 }
649}
650