Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions ScheduledTasksManager/Public/Get-StmScheduledTaskInfo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ function Get-StmScheduledTaskInfo {
}

$scheduledTasks = Get-StmScheduledTask @stmScheduledTaskParameters

# Get-ScheduledTaskInfo needs a CIM session for remote hosts, otherwise it
# falls back to the local Task Scheduler and returns HRESULT 0x80070002 for
# task names that exist on the remote host but not locally.
$infoCimSession = $null
if ($ComputerName -ne 'localhost' -and $ComputerName -ne '.' -and
$ComputerName -ne $env:COMPUTERNAME) {
$infoCimSessionParameters = @{ ComputerName = $ComputerName }
if ($PSBoundParameters.ContainsKey('Credential')) {
$infoCimSessionParameters['Credential'] = $Credential
}
$infoCimSession = New-StmCimSession @infoCimSessionParameters
}
}
}

Expand All @@ -217,7 +230,26 @@ function Get-StmScheduledTaskInfo {

foreach ($task in $scheduledTasks) {
try {
$scheduledTaskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath
if ($PSCmdlet.ParameterSetName -eq 'ByInputObject') {
# Pipe the task object straight through so Get-ScheduledTaskInfo reuses
# the CIM session the object was retrieved from. A task piped in from a
# remote Get-StmScheduledTask carries its originating session; rebuilding
# the call from TaskName/TaskPath (as the ByParameters path does) would
# silently fall back to the local Task Scheduler and return HRESULT
# 0x80070002 for tasks that only exist on the remote host.
$scheduledTaskInfo = $task | Get-ScheduledTaskInfo -ErrorAction Stop
}
else {
$getScheduledTaskInfoParameters = @{
TaskName = $task.TaskName
TaskPath = $task.TaskPath
ErrorAction = 'Stop'
}
if ($infoCimSession) {
$getScheduledTaskInfoParameters['CimSession'] = $infoCimSession
}
$scheduledTaskInfo = Get-ScheduledTaskInfo @getScheduledTaskInfoParameters
}

Write-Verbose "Merging properties for task '$($task.TaskName)'"
$mergeParameters = @{
Expand Down Expand Up @@ -275,12 +307,20 @@ function Get-StmScheduledTaskInfo {
)
}
$errorRecord = New-StmError @errorRecordParameters
$PSCmdlet.ThrowTerminatingError($errorRecord)
$PSCmdlet.WriteError($errorRecord)
continue
}
}
}

end {
if ($infoCimSession) {
# -WhatIf:$false: tearing down the CIM session is connection cleanup, not a
# state change the caller should be able to skip via WhatIfPreference. Without
# this, an inherited $WhatIfPreference would leak the remote session. Matches
# the Remove-CimSession pattern used elsewhere in the module (see 0.11.2).
Remove-CimSession -CimSession $infoCimSession -ErrorAction SilentlyContinue -WhatIf:$false
}
Comment thread
tablackburn marked this conversation as resolved.
Write-Verbose 'Finished Get-StmScheduledTaskInfo'
}
}
225 changes: 197 additions & 28 deletions tests/Get-StmScheduledTaskInfo.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {
ScheduledTaskInfoObject = $SecondObject
}
}

# The Issue-3 fix creates a CIM session for remote ComputerName values. Without
# mocking these, tests that pass -ComputerName 'Server01' would try to reach the
# real (nonexistent) host. New-MockObject returns a properly-typed CimSession
# so the patched code's -CimSession parameter binding succeeds.
Mock -CommandName New-StmCimSession -MockWith {
return (New-MockObject -Type 'Microsoft.Management.Infrastructure.CimSession')
}
Mock -CommandName Remove-CimSession -MockWith {}
}

It 'Should retrieve task info without parameters' {
Expand Down Expand Up @@ -190,6 +199,37 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {

Context 'When retrieving task info using pipeline input' {
BeforeEach {
# Build a real MSFT_ScheduledTask CimInstance so pipeline ByValue binding to
# Get-ScheduledTaskInfo's -InputObject succeeds. A faux PSCustomObject with an
# inserted type name does NOT bind by value, and Get-ScheduledTaskInfo's
# TaskName/TaskPath are not pipeline-by-property-name parameters. The task
# fields the function reads after the pipe are set as real CIM properties (the
# way an actual scheduled-task instance exposes them) rather than ETS members,
# which collide with CimInstance's built-in 'State' ScriptProperty.
function New-StmTestTaskCimInstance {
param($TaskName, $TaskPath = '\TestPath\', $State = 'Ready')
$newInstanceParameters = @{
TypeName = 'Microsoft.Management.Infrastructure.CimInstance'
ArgumentList = @('MSFT_ScheduledTask', 'Root/Microsoft/Windows/TaskScheduler')
}
$instance = New-Object @newInstanceParameters
$cimPropertyValues = [ordered]@{
TaskName = $TaskName
TaskPath = $TaskPath
State = $State
}
foreach ($propertyName in $cimPropertyValues.Keys) {
$property = [Microsoft.Management.Infrastructure.CimProperty]::Create(
$propertyName,
$cimPropertyValues[$propertyName],
[Microsoft.Management.Infrastructure.CimType]::String,
[Microsoft.Management.Infrastructure.CimFlags]::Property
)
$instance.CimInstanceProperties.Add($property)
}
return $instance
}

Mock -CommandName Get-ScheduledTaskInfo -MockWith {
param($TaskName, $TaskPath)
$mockInfo = [PSCustomObject]@{
Expand Down Expand Up @@ -219,36 +259,26 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {
}

It 'Should accept pipeline input from Get-StmScheduledTask' {
$mockTask = [PSCustomObject]@{
TaskName = 'PipelineTask'
TaskPath = '\TestPath\'
State = 'Ready'
}
$mockTask.PSObject.TypeNames.Insert(0, 'Microsoft.Management.Infrastructure.CimInstance#MSFT_ScheduledTask')
$mockTask = New-StmTestTaskCimInstance -TaskName 'PipelineTask'

$result = $mockTask | Get-StmScheduledTaskInfo

$result | Should -Not -BeNullOrEmpty
$result.TaskName | Should -Be 'PipelineTask'
Should -Invoke Get-ScheduledTaskInfo -Times 1 -Exactly
# The piped task must reach Get-ScheduledTaskInfo as -InputObject so its
# originating CIM session is reused, rather than being rebuilt by TaskName
# (which would fall back to the local Task Scheduler for a remote task).
Should -Invoke Get-ScheduledTaskInfo -Times 1 -ParameterFilter {
$null -ne $InputObject
}
}

It 'Should accept multiple tasks from pipeline' {
$mockTask1 = [PSCustomObject]@{
TaskName = 'Task1'
TaskPath = '\Path1\'
State = 'Ready'
}
$mockTask1.PSObject.TypeNames.Insert(0, 'Microsoft.Management.Infrastructure.CimInstance#MSFT_ScheduledTask')

$mockTask2 = [PSCustomObject]@{
TaskName = 'Task2'
TaskPath = '\Path2\'
State = 'Ready'
}
$mockTask2.PSObject.TypeNames.Insert(0, 'Microsoft.Management.Infrastructure.CimInstance#MSFT_ScheduledTask')

$mockTasks = @($mockTask1, $mockTask2)
$mockTasks = @(
New-StmTestTaskCimInstance -TaskName 'Task1' -TaskPath '\Path1\'
New-StmTestTaskCimInstance -TaskName 'Task2' -TaskPath '\Path2\'
)

$result = $mockTasks | Get-StmScheduledTaskInfo

Expand Down Expand Up @@ -290,7 +320,7 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {
}
}

It 'Should throw terminating error when Get-ScheduledTaskInfo fails' {
It 'Should write a non-terminating error (not throw) when Get-ScheduledTaskInfo fails' {
Mock -CommandName Get-ScheduledTaskInfo -MockWith {
throw 'Access Denied'
}
Expand All @@ -301,12 +331,26 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {
}
}

{
Get-StmScheduledTaskInfo -TaskName 'ErrorTask'
} | Should -Throw
# Calling directly (not via { } | Should -Not -Throw) for two reasons:
# 1. If the cmdlet throws, this It fails with the exception — which is what
# we want for a regression of the original ThrowTerminatingError bug.
# 2. A scriptblock wrapper would scope -ErrorVariable away from the outer $err.
$err = $null
$invokeParameters = @{
TaskName = 'ErrorTask'
ErrorVariable = 'err'
ErrorAction = 'SilentlyContinue'
}
Get-StmScheduledTaskInfo @invokeParameters

Should -Invoke Get-ScheduledTaskInfo -Times 1 -Exactly
Should -Invoke New-StmError -Times 1 -Exactly

# Filtered-match-count instead of total-count: Pester mock framework adds extra raw
# error records to $err when the mock body throws.
$structuredErrors = $err |
Where-Object { $_.FullyQualifiedErrorId -match 'ScheduledTaskInfoRetrievalFailed' }
@($structuredErrors).Count | Should -Be 1
}

It 'Should include task name in error message' {
Expand All @@ -320,9 +364,11 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {
}
}

{
Get-StmScheduledTaskInfo -TaskName 'ErrorTask'
} | Should -Throw
$invokeParameters = @{
TaskName = 'ErrorTask'
ErrorAction = 'SilentlyContinue'
}
Get-StmScheduledTaskInfo @invokeParameters

Should -Invoke New-StmError -Times 1 -Exactly -ParameterFilter {
$Message -like "*ErrorTask*"
Expand Down Expand Up @@ -387,5 +433,128 @@ InModuleScope -ModuleName 'ScheduledTasksManager' {
$result.TaskName | Should -Not -BeOfType [hashtable]
}
}

Context 'Remote-host CIM session plumbing (Issue 3 fix a)' {
BeforeEach {
Mock -CommandName Get-StmScheduledTask -MockWith {
$t = [PSCustomObject]@{
TaskName = 'RemoteTask'
TaskPath = '\'
State = 'Ready'
}
$t.PSObject.TypeNames.Insert(0, 'Microsoft.Management.Infrastructure.CimInstance#MSFT_ScheduledTask')
return $t
}
Mock -CommandName New-StmCimSession -MockWith {
# New-MockObject yields a properly-typed CimSession so the patched code's
# -CimSession parameter binding on Get-ScheduledTaskInfo succeeds.
return (New-MockObject -Type 'Microsoft.Management.Infrastructure.CimSession')
}
Mock -CommandName Get-ScheduledTaskInfo -MockWith {
return [PSCustomObject]@{
LastRunTime = (Get-Date).AddHours(-1)
LastTaskResult = 0
NextRunTime = (Get-Date).AddHours(1)
NumberOfMissedRuns = 0
}
}
Mock -CommandName Merge-Object -MockWith {
param($FirstObject, $SecondObject)
@{ TaskName = $FirstObject.TaskName; State = 'Ready' }
}
Mock -CommandName Remove-CimSession -MockWith {}
}

It 'Should pass -CimSession to Get-ScheduledTaskInfo when ComputerName is a remote host' {
Get-StmScheduledTaskInfo -ComputerName 'RemoteServer' -TaskName 'RemoteTask' | Out-Null
Should -Invoke Get-ScheduledTaskInfo -Times 1 -ParameterFilter {
$null -ne $CimSession
}
}

It 'Should NOT pass -CimSession to Get-ScheduledTaskInfo when ComputerName is localhost' {
Get-StmScheduledTaskInfo -ComputerName 'localhost' -TaskName 'RemoteTask' | Out-Null
Should -Invoke Get-ScheduledTaskInfo -Times 1 -ParameterFilter {
-not $PSBoundParameters.ContainsKey('CimSession')
}
}

It 'Should forward the supplied Credential to New-StmCimSession for a remote host' {
$credential = [System.Management.Automation.PSCredential]::new(
'TestUser',
(ConvertTo-SecureString 'TestPassword' -AsPlainText -Force)
)
$invokeParameters = @{
ComputerName = 'RemoteServer'
TaskName = 'RemoteTask'
Credential = $credential
}
Get-StmScheduledTaskInfo @invokeParameters | Out-Null

Should -Invoke New-StmCimSession -Times 1 -Exactly -ParameterFilter {
$Credential -eq $credential
}
# The remote session created for the lookup is torn down in the end block.
Should -Invoke Remove-CimSession -Times 1 -Exactly
}
}

Context 'Loop continues on per-task failure (Issue 3 fix b)' {
BeforeEach {
Mock -CommandName Get-StmScheduledTask -MockWith {
1..3 | ForEach-Object {
$t = [PSCustomObject]@{
TaskName = "Task$_"
TaskPath = '\'
State = 'Ready'
}
$t.PSObject.TypeNames.Insert(0, 'Microsoft.Management.Infrastructure.CimInstance#MSFT_ScheduledTask')
$t
}
}
# Use Write-Error -ErrorAction Stop instead of bare `throw` — a Pester mock body's
# `throw` behaves unpredictably (the exception was being captured many times via
# -ErrorVariable instead of going through the cmdlet's try/catch).
Mock -CommandName Get-ScheduledTaskInfo -ParameterFilter {
$TaskName -eq 'Task2'
} -MockWith {
Write-Error 'simulated CIM failure on Task2' -ErrorAction Stop
}
Mock -CommandName Get-ScheduledTaskInfo -MockWith {
return [PSCustomObject]@{
LastRunTime = (Get-Date).AddHours(-1)
LastTaskResult = 0
NextRunTime = (Get-Date).AddHours(1)
NumberOfMissedRuns = 0
}
}
Mock -CommandName Merge-Object -MockWith {
param($FirstObject, $SecondObject)
@{ TaskName = $FirstObject.TaskName; State = 'Ready' }
}
}

It 'Should process remaining tasks (and write a per-task error) when one task fails' {
$err = $null
$loopParameters = @{
TaskName = 'Task*'
ErrorVariable = 'err'
ErrorAction = 'SilentlyContinue'
}
$results = Get-StmScheduledTaskInfo @loopParameters

# Core contract: loop did not abort, output contains the surviving tasks
@($results).Count | Should -Be 2
@($results | ForEach-Object { $_.TaskName }) | Should -Be @('Task1', 'Task3')

# And: the failing task surfaced as a structured error with the right attribution.
# We assert filtered-match-count rather than total-count because Pester's mock
# framework can add extra raw error records to $err that aren't ours.
$structuredErrors = $err |
Where-Object { $_.FullyQualifiedErrorId -match 'ScheduledTaskInfoRetrievalFailed' }
@($structuredErrors).Count | Should -Be 1
$structuredErrors[0].TargetObject | Should -Be 'Task2'
}
}
}
}
Loading