Tests/Infra-ChangeTracker.Tests.ps1
|
BeforeAll { $ModulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\Infra-ChangeTracker.psd1' $ManifestPath = $ModulePath # Remove the module if already loaded so we get a fresh import if (Get-Module -Name 'Infra-ChangeTracker') { Remove-Module -Name 'Infra-ChangeTracker' -Force } Import-Module $ModulePath -Force -ErrorAction Stop } AfterAll { if (Get-Module -Name 'Infra-ChangeTracker') { Remove-Module -Name 'Infra-ChangeTracker' -Force } } Describe 'Module Loading' { It 'Should import the module without errors' { $Module = Get-Module -Name 'Infra-ChangeTracker' $Module | Should -Not -BeNullOrEmpty } It 'Should export exactly 5 public functions' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions.Count | Should -Be 5 } It 'Should export Invoke-ChangeAudit' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions | Should -Contain 'Invoke-ChangeAudit' } It 'Should export Get-ADChanges' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions | Should -Contain 'Get-ADChanges' } It 'Should export Get-GPOChanges' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions | Should -Contain 'Get-GPOChanges' } It 'Should export Get-DNSChanges' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions | Should -Contain 'Get-DNSChanges' } It 'Should export Get-ServerConfigChanges' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions | Should -Contain 'Get-ServerConfigChanges' } It 'Should NOT export private functions' { $ExportedFunctions = (Get-Module -Name 'Infra-ChangeTracker').ExportedFunctions.Keys $ExportedFunctions | Should -Not -Contain 'New-HtmlDashboard' $ExportedFunctions | Should -Not -Contain 'Get-ChangeSnapshot' } } Describe 'Manifest Validation' { BeforeAll { $Manifest = Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop } It 'Should have the correct GUID' { $Manifest.GUID.ToString() | Should -Be 'a7b8c9d0-4e35-4ab1-f2c3-8e9f0a1b2c34' } It 'Should require PowerShell 5.1 or higher' { $Manifest.PowerShellVersion | Should -Be '5.1' } It 'Should have the correct author' { $Manifest.Author | Should -BeLike '*Larry Roberts*' } It 'Should have a description' { $Manifest.Description | Should -Not -BeNullOrEmpty $Manifest.Description | Should -BeLike '*change tracking*' } It 'Should have the expected tags' { $Tags = $Manifest.PrivateData.PSData.Tags $Tags | Should -Contain 'ChangeTracking' $Tags | Should -Contain 'Audit' $Tags | Should -Contain 'ActiveDirectory' $Tags | Should -Contain 'GPO' $Tags | Should -Contain 'DNS' $Tags | Should -Contain 'Compliance' $Tags | Should -Contain 'Security' $Tags | Should -Contain 'Monitoring' } It 'Should have a ProjectUri' { $Manifest.PrivateData.PSData.ProjectUri | Should -Be 'https://github.com/larro1991/Infra-ChangeTracker' } It 'Should have a LicenseUri' { $Manifest.PrivateData.PSData.LicenseUri | Should -Be 'https://github.com/larro1991/Infra-ChangeTracker/blob/master/LICENSE' } } Describe 'Parameter Validation' { Context 'Get-ADChanges' { It 'Should accept valid HoursBack values' { { Get-ADChanges -HoursBack 1 -ErrorAction Stop } | Should -Not -Throw -Because 'HoursBack=1 is within range' } -Skip It 'Should reject HoursBack below 1' { { Get-ADChanges -HoursBack 0 } | Should -Throw } It 'Should reject HoursBack above 720' { { Get-ADChanges -HoursBack 721 } | Should -Throw } It 'Should validate ChangeType values' { { Get-ADChanges -ChangeType 'InvalidType' } | Should -Throw } It 'Should accept valid ChangeType values' { foreach ($Type in @('All', 'Users', 'Groups', 'Computers', 'OUs')) { $Cmd = Get-Command -Name 'Get-ADChanges' $ValidValues = $Cmd.Parameters['ChangeType'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | Select-Object -ExpandProperty ValidValues $ValidValues | Should -Contain $Type } } } Context 'Get-GPOChanges' { It 'Should reject HoursBack below 1' { { Get-GPOChanges -HoursBack 0 } | Should -Throw } It 'Should reject HoursBack above 720' { { Get-GPOChanges -HoursBack 721 } | Should -Throw } It 'Should have IncludeLinkChanges as a switch parameter' { $Cmd = Get-Command -Name 'Get-GPOChanges' $Cmd.Parameters['IncludeLinkChanges'].SwitchParameter | Should -BeTrue } } Context 'Get-DNSChanges' { It 'Should reject HoursBack below 1' { { Get-DNSChanges -HoursBack 0 } | Should -Throw } It 'Should reject HoursBack above 720' { { Get-DNSChanges -HoursBack 721 } | Should -Throw } It 'Should accept ZoneName as string array' { $Cmd = Get-Command -Name 'Get-DNSChanges' $Cmd.Parameters['ZoneName'].ParameterType.Name | Should -Be 'String[]' } } Context 'Get-ServerConfigChanges' { It 'Should require ComputerName parameter' { $Cmd = Get-Command -Name 'Get-ServerConfigChanges' $Cmd.Parameters['ComputerName'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.Mandatory } | Should -Not -BeNullOrEmpty } It 'Should reject HoursBack below 1' { { Get-ServerConfigChanges -ComputerName 'SRV01' -HoursBack 0 } | Should -Throw } It 'Should reject HoursBack above 720' { { Get-ServerConfigChanges -ComputerName 'SRV01' -HoursBack 721 } | Should -Throw } It 'Should accept ComputerName as string array' { $Cmd = Get-Command -Name 'Get-ServerConfigChanges' $Cmd.Parameters['ComputerName'].ParameterType.Name | Should -Be 'String[]' } } Context 'Invoke-ChangeAudit' { It 'Should reject HoursBack below 1' { { Invoke-ChangeAudit -HoursBack 0 } | Should -Throw } It 'Should reject HoursBack above 720' { { Invoke-ChangeAudit -HoursBack 721 } | Should -Throw } It 'Should have IncludeDNS as a switch parameter' { $Cmd = Get-Command -Name 'Invoke-ChangeAudit' $Cmd.Parameters['IncludeDNS'].SwitchParameter | Should -BeTrue } It 'Should have IncludeServerConfig as a switch parameter' { $Cmd = Get-Command -Name 'Invoke-ChangeAudit' $Cmd.Parameters['IncludeServerConfig'].SwitchParameter | Should -BeTrue } It 'Should default HoursBack to 24' { $Cmd = Get-Command -Name 'Invoke-ChangeAudit' $Cmd.Parameters['HoursBack'].Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | ForEach-Object { $_.Mandatory } | Should -Not -BeTrue } } } Describe 'Get-ADChanges - Mock Tests' { BeforeAll { # Re-import to get access to internal functions for mocking if (Get-Module -Name 'Infra-ChangeTracker') { Remove-Module -Name 'Infra-ChangeTracker' -Force } Import-Module (Join-Path -Path $PSScriptRoot -ChildPath '..\Infra-ChangeTracker.psd1') -Force } It 'Should parse event 4732 (group member add) correctly' { # Mock Get-ADObject to return nothing (skip Phase 1) Mock -CommandName Get-ADObject -MockWith { return @() } -ModuleName 'Infra-ChangeTracker' # Mock Get-ADDomainController to return a single DC Mock -CommandName Get-ADDomainController -MockWith { return @([PSCustomObject]@{ HostName = 'DC01.contoso.com' }) } -ModuleName 'Infra-ChangeTracker' # Create a mock event for 4732 (member added to group) $MockEventXml = @" <Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"> <System> <EventID>4732</EventID> <TimeCreated SystemTime="2026-02-15T14:30:00.000Z"/> </System> <EventData> <Data Name="SubjectUserName">admin1</Data> <Data Name="SubjectDomainName">CONTOSO</Data> <Data Name="TargetUserName">Domain Admins</Data> <Data Name="TargetDomainName">CONTOSO</Data> <Data Name="MemberName">CN=John Smith,OU=Users,DC=contoso,DC=com</Data> <Data Name="MemberSid">S-1-5-21-1234567890-1234567890-1234567890-1234</Data> </EventData> </Event> "@ $MockEvent = [PSCustomObject]@{ Id = 4732 TimeCreated = [datetime]'2026-02-15 14:30:00' Message = 'A member was added to a security-enabled global group.' } $MockEvent | Add-Member -MemberType ScriptMethod -Name 'ToXml' -Value { return $MockEventXml } -Force Mock -CommandName Get-WinEvent -MockWith { return @($MockEvent) } -ModuleName 'Infra-ChangeTracker' $Results = Get-ADChanges -HoursBack 24 -ChangeType Groups $Results | Should -Not -BeNullOrEmpty $Results.Count | Should -BeGreaterOrEqual 1 $GroupChange = $Results | Where-Object { $_.ObjectName -eq 'Domain Admins' } | Select-Object -First 1 $GroupChange | Should -Not -BeNullOrEmpty $GroupChange.ChangeType | Should -Be 'MemberAdded' $GroupChange.ObjectType | Should -Be 'Group' $GroupChange.ChangedBy | Should -Be 'CONTOSO\admin1' $GroupChange.Severity | Should -Be 'Critical' $GroupChange.Category | Should -Be 'ActiveDirectory' } It 'Should query AD objects modified within the timeframe' { $MockADObject = [PSCustomObject]@{ Name = 'TestUser' objectClass = 'user' whenChanged = (Get-Date).AddHours(-2) whenCreated = (Get-Date).AddDays(-30) DistinguishedName = 'CN=TestUser,OU=Users,DC=contoso,DC=com' 'msDS-ReplAttributeMetaData' = $null } Mock -CommandName Get-ADObject -MockWith { return @($MockADObject) } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-ADDomainController -MockWith { return @() } -ModuleName 'Infra-ChangeTracker' $Results = Get-ADChanges -HoursBack 24 -ChangeType Users $Results | Should -Not -BeNullOrEmpty $UserChange = $Results | Where-Object { $_.ObjectName -eq 'TestUser' } | Select-Object -First 1 $UserChange | Should -Not -BeNullOrEmpty $UserChange.ChangeType | Should -Be 'Modified' $UserChange.ObjectType | Should -Be 'User' $UserChange.Category | Should -Be 'ActiveDirectory' } } Describe 'Get-GPOChanges - Mock Tests' { It 'Should detect modified GPOs via version change' { $MockGPO = [PSCustomObject]@{ DisplayName = 'Default Domain Policy' Id = [guid]'31b2f340-016d-11d2-945f-00c04fb984f9' GpoStatus = 'AllSettingsEnabled' CreationTime = (Get-Date).AddDays(-365) ModificationTime = (Get-Date).AddHours(-2) Owner = 'CONTOSO\Domain Admins' User = [PSCustomObject]@{ DSVersion = 5 } Computer = [PSCustomObject]@{ DSVersion = 12 } } Mock -CommandName Get-GPO -MockWith { if ($PSBoundParameters.ContainsKey('All')) { return @($MockGPO) } if ($PSBoundParameters.ContainsKey('Guid')) { return $MockGPO } } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-ADDomain -MockWith { return [PSCustomObject]@{ DNSRoot = 'contoso.com' } } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Test-Path -MockWith { return $false } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-GPOReport -MockWith { return '<GPO xmlns="http://www.microsoft.com/GroupPolicy/Settings"></GPO>' } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-ADDomainController -MockWith { return @() } -ModuleName 'Infra-ChangeTracker' $Results = Get-GPOChanges -HoursBack 24 $Results | Should -Not -BeNullOrEmpty $GPOChange = $Results | Where-Object { $_.ObjectName -eq 'Default Domain Policy' } | Select-Object -First 1 $GPOChange | Should -Not -BeNullOrEmpty $GPOChange.ChangeType | Should -Be 'Modified' $GPOChange.Severity | Should -Be 'High' $GPOChange.Category | Should -Be 'GroupPolicy' $GPOChange.NewValue | Should -BeLike '*User: v5*Computer: v12*' } It 'Should detect newly created GPOs' { $NewGPO = [PSCustomObject]@{ DisplayName = 'New Test Policy' Id = [guid]::NewGuid() GpoStatus = 'AllSettingsEnabled' CreationTime = (Get-Date).AddHours(-1) ModificationTime = (Get-Date).AddHours(-1) Owner = 'CONTOSO\admin1' User = [PSCustomObject]@{ DSVersion = 0 } Computer = [PSCustomObject]@{ DSVersion = 0 } } Mock -CommandName Get-GPO -MockWith { if ($PSBoundParameters.ContainsKey('All')) { return @($NewGPO) } if ($PSBoundParameters.ContainsKey('Guid')) { return $NewGPO } } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-ADDomain -MockWith { return [PSCustomObject]@{ DNSRoot = 'contoso.com' } } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Test-Path -MockWith { return $false } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-GPOReport -MockWith { return '<GPO xmlns="http://www.microsoft.com/GroupPolicy/Settings"></GPO>' } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-ADDomainController -MockWith { return @() } -ModuleName 'Infra-ChangeTracker' $Results = Get-GPOChanges -HoursBack 24 $Results | Should -Not -BeNullOrEmpty $NewGPOChange = $Results | Where-Object { $_.ObjectName -eq 'New Test Policy' } | Select-Object -First 1 $NewGPOChange | Should -Not -BeNullOrEmpty $NewGPOChange.ChangeType | Should -Be 'Created' } } Describe 'Get-DNSChanges - Mock Tests' { It 'Should parse DNS audit events for record changes' { $MockDnsEventXml = @" <Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"> <System> <EventID>513</EventID> <TimeCreated SystemTime="2026-02-15T10:00:00.000Z"/> </System> <EventData> <Data Name="ZONE">contoso.com</Data> <Data Name="QNAME">newserver</Data> <Data Name="QTYPE">1</Data> <Data Name="DATA">10.0.1.50</Data> <Data Name="Source">CONTOSO\admin1</Data> </EventData> </Event> "@ $MockDnsEvent = [PSCustomObject]@{ Id = 513 TimeCreated = [datetime]'2026-02-15 10:00:00' Message = 'Zone record added in zone contoso.com' } $MockDnsEvent | Add-Member -MemberType ScriptMethod -Name 'ToXml' -Value { return $MockDnsEventXml } -Force Mock -CommandName Get-DnsServerZone -MockWith { return @([PSCustomObject]@{ ZoneName = 'contoso.com' ZoneType = 'Primary' IsAutoCreated = $false }) } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-WinEvent -MockWith { return @($MockDnsEvent) } -ModuleName 'Infra-ChangeTracker' Mock -CommandName Get-DnsServerResourceRecord -MockWith { return @() } -ModuleName 'Infra-ChangeTracker' $Results = Get-DNSChanges -HoursBack 24 $Results | Should -Not -BeNullOrEmpty $DnsChange = $Results | Select-Object -First 1 $DnsChange.ChangeType | Should -Be 'Added' $DnsChange.Category | Should -Be 'DNS' $DnsChange.ObjectName | Should -Be 'newserver' $DnsChange.ObjectType | Should -Be 'A' $DnsChange.NewValue | Should -Be '10.0.1.50' } } Describe 'Get-ServerConfigChanges - Mock Tests' { It 'Should detect service changes via Invoke-Command' { Mock -CommandName Test-Connection -MockWith { return $true } -ModuleName 'Infra-ChangeTracker' $MockRemoteResults = @( [PSCustomObject]@{ ChangeTime = [datetime]'2026-02-15 09:00:00' ChangeType = 'Created' ChangeCategory = 'Service' ObjectName = 'SuspiciousSvc' OldValue = '' NewValue = 'Path: C:\Temp\svc.exe, Account: LocalSystem' ChangedBy = 'LocalSystem' Detail = "New service 'SuspiciousSvc' installed (Path: C:\Temp\svc.exe)" Source = 'System Event 7045' Severity = 'High' }, [PSCustomObject]@{ ChangeTime = [datetime]'2026-02-15 11:00:00' ChangeType = 'Modified' ChangeCategory = 'ScheduledTask' ObjectName = '\Microsoft\Windows\Backup\NightlyBackup' OldValue = '' NewValue = '' ChangedBy = 'CONTOSO\admin2' Detail = "Scheduled task '\Microsoft\Windows\Backup\NightlyBackup' Modified by CONTOSO\admin2" Source = 'TaskScheduler Event 140' Severity = 'Low' } ) Mock -CommandName Invoke-Command -MockWith { return $MockRemoteResults } -ModuleName 'Infra-ChangeTracker' $Results = Get-ServerConfigChanges -ComputerName 'WEB01' -HoursBack 24 $Results | Should -Not -BeNullOrEmpty $Results.Count | Should -Be 2 $ServiceChange = $Results | Where-Object { $_.ObjectType -eq 'Service' } $ServiceChange | Should -Not -BeNullOrEmpty $ServiceChange.ChangeType | Should -Be 'Created' $ServiceChange.ObjectName | Should -Be 'SuspiciousSvc' $ServiceChange.Category | Should -Be 'ServerConfig' $ServiceChange.Severity | Should -Be 'High' $TaskChange = $Results | Where-Object { $_.ObjectType -eq 'ScheduledTask' } $TaskChange | Should -Not -BeNullOrEmpty $TaskChange.ChangeType | Should -Be 'Modified' } It 'Should handle unreachable servers gracefully' { Mock -CommandName Test-Connection -MockWith { return $false } -ModuleName 'Infra-ChangeTracker' $Results = Get-ServerConfigChanges -ComputerName 'OFFLINE01' -HoursBack 24 -WarningAction SilentlyContinue $Results | Should -Not -BeNullOrEmpty $ErrorResult = $Results | Where-Object { $_.ChangeType -eq 'Error' } $ErrorResult | Should -Not -BeNullOrEmpty $ErrorResult.Detail | Should -BeLike '*unreachable*' } } Describe 'New-HtmlDashboard - Integration Tests' { It 'Should generate a valid HTML file' { $TestChanges = @( [PSCustomObject]@{ ChangeTime = (Get-Date).AddHours(-3) ChangeType = 'MemberAdded' Category = 'ActiveDirectory' ObjectName = 'Domain Admins' ObjectType = 'Group' ChangedBy = 'CONTOSO\admin1' OldValue = '' NewValue = 'CN=John Smith,OU=Users,DC=contoso,DC=com' Detail = "Member added to Domain Admins by CONTOSO\admin1" Source = 'Security Event 4732 on DC01' Severity = 'Critical' }, [PSCustomObject]@{ ChangeTime = (Get-Date).AddHours(-1) ChangeType = 'Modified' Category = 'GroupPolicy' ObjectName = 'Default Domain Policy' ObjectType = 'GPO' ChangedBy = 'CONTOSO\admin2' OldValue = '' NewValue = 'User: v5, Computer: v12' Detail = "GPO 'Default Domain Policy' modified" Source = 'GPO Query' Severity = 'High' } ) $TestOutputPath = Join-Path -Path $env:TEMP -ChildPath "ICT_Test_$(Get-Random).html" try { # Call the private function via module scope & (Get-Module -Name 'Infra-ChangeTracker') { New-HtmlDashboard -Changes $args[0] -OutputPath $args[1] -HoursBack 24 } $TestChanges $TestOutputPath Test-Path -Path $TestOutputPath | Should -BeTrue $Content = Get-Content -Path $TestOutputPath -Raw -Encoding UTF8 $Content | Should -BeLike '*Infrastructure Change Report*' $Content | Should -BeLike '*Domain Admins*' $Content | Should -BeLike '*Default Domain Policy*' $Content | Should -BeLike '*#3fb950*' $Content | Should -BeLike '*severity-critical*' } finally { if (Test-Path -Path $TestOutputPath) { Remove-Item -Path $TestOutputPath -Force } } } } |