tests/Get-MsecAzureDevOpsWorkItem.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecAzureDevOpsWorkItem. # # The traps here are specific to Azure DevOps rather than to security: # # 'OPEN' IS NOT A STATE NAME. Agile uses New/Active/Resolved/Closed, Scrum uses # New/Approved/Committed/Done, Basic uses To Do/Doing/Done. Filtering on a state name returns # nothing on a process that does not use it, so -OpenOnly works on the state CATEGORY. # # AN UNCLASSIFIABLE STATE IS KEPT, NOT DROPPED. Excluding an item because msec could not work # out whether it was closed would quietly shrink the list someone is using to chase work. # # THE HELPER ALREADY UNWRAPS 'value'. workitemsbatch returns {count, value:[...]} and # Invoke-MsecAzureDevOpsRequest hands back the array; taking .value again yields nothing, and # the symptom is a correct row COUNT with every field blank. That happened while writing this. # # WIQL STRING LITERALS ARE SINGLE-QUOTED, so an apostrophe in a tag has to be doubled or the # query fails to parse. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecAzureDevOpsWorkItem' { It 'reads fields from the batch response the helper already unwrapped' { $rows = InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @([pscustomobject]@{ id = 1 }) } } elseif ($Path -match 'workitemsbatch') { # The helper returns the items THEMSELVES, not an envelope. @([pscustomobject]@{ id = 1; fields = [pscustomobject]@{ 'System.WorkItemType' = 'Task'; 'System.Title' = 'Do the thing' 'System.State' = 'To Do'; 'System.Tags' = 'security' 'System.TeamProject' = 'Sec' 'System.CreatedDate' = (Get-Date).AddDays(-10).ToString('o') } }) } elseif ($Path -match 'states') { @([pscustomobject]@{ name = 'To Do'; category = 'Proposed' }) } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec } $rows.Count | Should -Be 1 $rows[0].Title | Should -Be 'Do the thing' $rows[0].Tags | Should -Be @('security') $rows[0].StateCategory | Should -Be 'Proposed' $rows[0].AgeDays | Should -BeGreaterOrEqual 9 } It 'takes BacklogRank from whichever rank field the process populates' { $rows = InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @([pscustomobject]@{id=1},[pscustomobject]@{id=2},[pscustomobject]@{id=3}) } } elseif ($Path -match 'workitemsbatch') { @( # Scrum writes BacklogPriority... [pscustomobject]@{ id=1; fields=[pscustomobject]@{ 'System.WorkItemType'='T'; 'System.State'='New'; 'System.TeamProject'='P' 'Microsoft.VSTS.Common.BacklogPriority'=1418086018 } } # ...Agile and CMMI write StackRank. [pscustomobject]@{ id=2; fields=[pscustomobject]@{ 'System.WorkItemType'='T'; 'System.State'='New'; 'System.TeamProject'='P' 'Microsoft.VSTS.Common.StackRank'=1999.5 } } # Never ranked on a backlog - a real and common state. [pscustomobject]@{ id=3; fields=[pscustomobject]@{ 'System.WorkItemType'='T'; 'System.State'='New'; 'System.TeamProject'='P' } } ) } elseif ($Path -match 'states') { @([pscustomobject]@{ name='New'; category='Proposed' }) } } Get-MsecAzureDevOpsWorkItem -Organization org -Project P } ($rows | Where-Object Id -eq 1).BacklogRank | Should -Be 1418086018 ($rows | Where-Object Id -eq 2).BacklogRank | Should -Be 1999.5 # 0 would sort an unranked item to the top as though someone had put it first. ($rows | Where-Object Id -eq 3).BacklogRank | Should -BeNullOrEmpty } It 'returns Tags as an array so -contains matches a multi-tag item' { $rows = InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @([pscustomobject]@{ id = 1 }) } } elseif ($Path -match 'workitemsbatch') { @([pscustomobject]@{ id=1; fields=[pscustomobject]@{ 'System.WorkItemType'='Task'; 'System.State'='To Do'; 'System.TeamProject'='Sec' 'System.Title'='Multi-tagged'; 'System.Tags'='Exchange; Internal IT' } }) } elseif ($Path -match 'states') { @([pscustomobject]@{ name='To Do'; category='Proposed' }) } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec } # Azure DevOps sends 'Exchange; Internal IT'. Keeping that string forced callers onto # -like '*Internal IT*', which also matches 'Internal IT Legacy', and made -contains # and -in return nothing at all for any item with more than one tag. $rows[0].Tags | Should -HaveCount 2 $rows[0].Tags | Should -Contain 'Internal IT' @($rows | Where-Object Tags -contains 'Internal IT') | Should -HaveCount 1 } It 'filters -OpenOnly by state CATEGORY, not by state name' { $rows = InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @([pscustomobject]@{id=1},[pscustomobject]@{id=2},[pscustomobject]@{id=3}) } } elseif ($Path -match 'workitemsbatch') { @( [pscustomobject]@{ id=1; fields=[pscustomobject]@{ 'System.WorkItemType'='Task'; 'System.State'='Committed'; 'System.TeamProject'='Sec'; 'System.Title'='In progress' } } [pscustomobject]@{ id=2; fields=[pscustomobject]@{ 'System.WorkItemType'='Task'; 'System.State'='Done'; 'System.TeamProject'='Sec'; 'System.Title'='Finished' } } [pscustomobject]@{ id=3; fields=[pscustomobject]@{ 'System.WorkItemType'='Task'; 'System.State'='Removed'; 'System.TeamProject'='Sec'; 'System.Title'='Dropped' } } ) } elseif ($Path -match 'states') { # Scrum names - no state called 'Closed' or 'Active' anywhere. @( [pscustomobject]@{ name='Committed'; category='InProgress' } [pscustomobject]@{ name='Done'; category='Completed' } [pscustomobject]@{ name='Removed'; category='Removed' } ) } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec -OpenOnly } @($rows).Count | Should -Be 1 $rows[0].Title | Should -Be 'In progress' } It 'keeps an item whose state could not be classified under -OpenOnly' { $rows = InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @([pscustomobject]@{ id = 1 }) } } elseif ($Path -match 'workitemsbatch') { @([pscustomobject]@{ id=1; fields=[pscustomobject]@{ 'System.WorkItemType'='Task'; 'System.State'='Mystery'; 'System.TeamProject'='Sec'; 'System.Title'='Unknown state' } }) } elseif ($Path -match 'states') { throw 'Access denied' } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec -OpenOnly -WarningAction SilentlyContinue } # Dropping it would shrink the very list someone uses to chase outstanding work. @($rows).Count | Should -Be 1 $rows[0].StateCategory | Should -BeNullOrEmpty } It 'reports AgeDays as null rather than zero when the created date is missing' { $rows = InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @([pscustomobject]@{ id = 1 }) } } elseif ($Path -match 'workitemsbatch') { @([pscustomobject]@{ id=1; fields=[pscustomobject]@{ 'System.WorkItemType'='Task'; 'System.State'='To Do'; 'System.TeamProject'='Sec'; 'System.Title'='No date' } }) } elseif ($Path -match 'states') { @([pscustomobject]@{ name='To Do'; category='Proposed' }) } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec } # 0 would read as "created today", which is the opposite of an unknown age. $rows[0].AgeDays | Should -BeNullOrEmpty } It 'doubles an apostrophe in a tag so the WIQL parses' { InModuleScope msec { $script:SeenQuery = $null Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { $script:SeenQuery = $Body.query; [pscustomobject]@{ workItems = @() } } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec -Tag "Anton's" | Out-Null $script:SeenQuery | Should -Match "CONTAINS 'Anton''s'" } } It 'builds tag, area path and type filters into the query' { InModuleScope msec { $script:SeenQuery = $null Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { $script:SeenQuery = $Body.query; [pscustomobject]@{ workItems = @() } } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec -Tag security -AreaPath 'Sec\Platform' -Type Task, Bug | Out-Null $script:SeenQuery | Should -Match "\[System\.Tags\] CONTAINS 'security'" # UNDER, not =, so child areas are included. $script:SeenQuery | Should -Match "\[System\.AreaPath\] UNDER 'Sec\\Platform'" $script:SeenQuery | Should -Match "\[System\.WorkItemType\] = 'Task' OR \[System\.WorkItemType\] = 'Bug'" } } It 'names both the organization and the project when Azure DevOps answers 404' { InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { throw 'Response status code does not indicate success: 404 (Not Found).' } # The same 404 covers a misspelled org, a misspelled project, and one the identity # cannot see - so the message must not send the reader to check only one of them. { Get-MsecAzureDevOpsWorkItem -Organization pcgsolution -Project Security } | Should -Throw "*organization 'pcgsolution' or project 'Security'*" } } It 'resolves a team to all its area paths, honouring includeChildren per path' { InModuleScope msec { $script:SeenQuery = $null Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'teamfieldvalues') { [pscustomobject]@{ field = [pscustomobject]@{ referenceName = 'System.AreaPath' } values = @( [pscustomobject]@{ value = 'P\Basics\Security'; includeChildren = $true } [pscustomobject]@{ value = 'P\Management - Sec'; includeChildren = $false } ) } } elseif ($Path -match 'wiql') { $script:SeenQuery = $Body.query; [pscustomobject]@{ workItems = @() } } } Get-MsecAzureDevOpsWorkItem -Organization org -Project P -Team 'Sec team' | Out-Null # UNDER for everything would pull in sub-areas the team deliberately excluded; # = for everything would drop the sub-areas that are most of a backlog. $script:SeenQuery | Should -Match "\[System\.AreaPath\] UNDER 'P\\Basics\\Security'" $script:SeenQuery | Should -Match "\[System\.AreaPath\] = 'P\\Management - Sec'" # Both paths in one OR group, so a later AND cannot bind to only the last one. $script:SeenQuery | Should -Match "\(\[System\.AreaPath\].*OR.*\)" } } It 'requires -Project when -Team is given' { InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { } { Get-MsecAzureDevOpsWorkItem -Organization org -Team 'Sec team' } | Should -Throw '*-Team requires -Project*' } } It 'says team names are per-project and case-sensitive when the team cannot be read' { InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'teamfieldvalues') { throw 'Response status code does not indicate success: 404 (Not Found).' } } { Get-MsecAzureDevOpsWorkItem -Organization org -Project P -Team 'security and compliance' } | Should -Throw '*must match exactly*' } } It 'ORs several -AreaPath values rather than keeping only the last' { InModuleScope msec { $script:SeenQuery = $null Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { $script:SeenQuery = $Body.query; [pscustomobject]@{ workItems = @() } } } Get-MsecAzureDevOpsWorkItem -Organization org -Project P -AreaPath 'P\One', 'P\Two' | Out-Null $script:SeenQuery | Should -Match "UNDER 'P\\One' OR \[System\.AreaPath\] UNDER 'P\\Two'" } } It 'narrows server-side with -ChangedWithinDays' { InModuleScope msec { $script:SeenQuery = $null Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { $script:SeenQuery = $Body.query; [pscustomobject]@{ workItems = @() } } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec -ChangedWithinDays 30 | Out-Null # @Today is resolved by Azure DevOps, not locally, so the clause must go to the # server verbatim rather than being turned into a date on this machine. $script:SeenQuery | Should -Match '\[System\.ChangedDate\] >= @Today - 30' } } It 'explains that -MaxItems cannot help when the 20,000 limit is hit' { InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { throw 'VS402337: The number of work items returned exceeds the size limit of 20000.' } # The limit is applied before any result is returned, so a client-side cap is no # help - saying "raise -MaxItems" would send the reader round in a circle. { Get-MsecAzureDevOpsWorkItem -Organization org -Project Big } | Should -Throw '*-MaxItems cannot help here*' { Get-MsecAzureDevOpsWorkItem -Organization org -Project Big } | Should -Throw '*-ChangedWithinDays*' } } It 'warns that the answer is truncated when more items match than -MaxItems' { InModuleScope msec { Mock Invoke-MsecAzureDevOpsRequest -MockWith { if ($Path -match 'wiql') { [pscustomobject]@{ workItems = @(1..5 | ForEach-Object { [pscustomobject]@{ id = $_ } }) } } elseif ($Path -match 'workitemsbatch') { @() } } Get-MsecAzureDevOpsWorkItem -Organization org -Project Sec -MaxItems 2 -WarningVariable w -WarningAction SilentlyContinue | Out-Null $script:captured = $w } $captured = InModuleScope msec { $script:captured } # Silent truncation of a backlog report is how a count gets quoted as complete. "$captured" | Should -Match 'truncated answer' } } |