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
            }
        }
    }
}