tests/Core.Tests.ps1

#Requires -Version 7.0
#Requires -Modules Pester

<#
.SYNOPSIS
Pester tests for IntuneCD core functions

.DESCRIPTION
Tests for core backup infrastructure functions:
- Invoke-GraphRequest2
- Remove-VolatileKeys
- Save-BackupItem
- Get-ScopeTags
- Resolve-Assignments
- Resolve-ScopeTagNames
#>


BeforeAll {
    # import the module being tested
    Import-Module "$PSScriptRoot/../IntuneBackup.psm1" -Force
}

Describe 'Remove-VolatileKeys' {
    Context 'Flat object with volatile keys' {
        It 'removes volatile keys from a flat object' {
            $obj = [PSCustomObject]@{
                displayName           = 'Test Policy'
                id                    = 'abc123'
                version               = 1
                createdDateTime       = '2024-01-01'
                lastModifiedDateTime  = 'never'
                description           = 'My Description'
            }

            $result = $obj | Remove-VolatileKeys
            
            $result.PSObject.Properties.Name | Should -Contain 'displayName'
            $result.PSObject.Properties.Name | Should -Contain 'description'
            $result.PSObject.Properties.Name | Should -Contain 'id'
            $result.PSObject.Properties.Name | Should -Not -Contain 'version'
            $result.PSObject.Properties.Name | Should -Not -Contain 'createdDateTime'
            $result.PSObject.Properties.Name | Should -Not -Contain 'lastModifiedDateTime'
        }
    }

    Context 'Nested objects with volatile keys' {
        It 'recursively removes volatile keys from nested objects' {
            $obj = [PSCustomObject]@{
                displayName  = 'Parent'
                id           = 'parent-id'
                settings     = [PSCustomObject]@{
                    name = 'Setting1'
                    id   = 'setting-id'
                }
            }

            $result = $obj | Remove-VolatileKeys
            
            $result.settings.PSObject.Properties.Name | Should -Contain 'id'
            $result.settings.PSObject.Properties.Name | Should -Contain 'name'
        }
    }

    Context 'Arrays of objects with volatile keys' {
        It 'handles arrays of objects' {
            $arr = @(
                [PSCustomObject]@{ displayName = 'Item1'; id = 'id1'; value = 'val1' },
                [PSCustomObject]@{ displayName = 'Item2'; id = 'id2'; value = 'val2' }
            )

            $result = $arr | Remove-VolatileKeys
            
            $result.Count | Should -Be 2
            $result[0].PSObject.Properties.Name | Should -Contain 'id'
            $result[0].PSObject.Properties.Name | Should -Contain 'displayName'
            $result[1].PSObject.Properties.Name | Should -Contain 'id'
        }
    }

    Context 'Preserves non-volatile keys' {
        It 'does not remove non-volatile keys' {
            $obj = [PSCustomObject]@{
                displayName = 'Policy'
                description = 'Description'
                settings    = 'Some Value'
                enabled     = $true
            }

            $result = $obj | Remove-VolatileKeys
            
            $result.displayName | Should -Be 'Policy'
            $result.description | Should -Be 'Description'
            $result.settings | Should -Be 'Some Value'
            $result.enabled | Should -Be $true
        }
    }

    Context 'AdditionalExcludedProperties parameter' {
        It 'removes caller-provided properties in addition to defaults' {
            $obj = [PSCustomObject]@{
                displayName = 'Policy'
                id = 'keep-by-default'
                description = 'remove-me'
                createdDateTime = 'already-volatile'
                nested = [PSCustomObject]@{
                    id = 'nested-id'
                    description = 'nested-desc'
                }
            }

            $result = Remove-VolatileKeys -InputObject $obj -AdditionalExcludedProperties @('id', 'description')

            $result.PSObject.Properties.Name | Should -Contain 'displayName'
            $result.PSObject.Properties.Name | Should -Not -Contain 'id'
            $result.PSObject.Properties.Name | Should -Not -Contain 'description'
            $result.PSObject.Properties.Name | Should -Not -Contain 'createdDateTime'
            $result.nested.PSObject.Properties.Name | Should -Not -Contain 'id'
            $result.nested.PSObject.Properties.Name | Should -Not -Contain 'description'
        }

        It 'keeps default behavior when no additional properties are passed' {
            $obj = [PSCustomObject]@{
                displayName = 'Policy'
                id = 'kept'
                createdDateTime = 'remove-me'
            }

            $result = Remove-VolatileKeys -InputObject $obj

            $result.PSObject.Properties.Name | Should -Contain 'displayName'
            $result.PSObject.Properties.Name | Should -Contain 'id'
            $result.PSObject.Properties.Name | Should -Not -Contain 'createdDateTime'
        }
    }

    Context 'Null and empty inputs' {
        It 'handles null input' {
            $result = $null | Remove-VolatileKeys
            $result | Should -BeNullOrEmpty
        }

        It 'handles empty array' {
            $result = @() | Remove-VolatileKeys
            $result.Count | Should -Be 0
        }
    }
}

Describe 'Save-BackupItem' {
    Context 'File creation and folder handling' {
        It 'creates the folder if it does not exist' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{ displayName = 'TestItem'; value = 'test' }
            
            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder
                
                Test-Path -Path $testFolder -PathType Container | Should -Be $true
                Test-Path -Path $result -PathType Leaf | Should -Be $true
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
    }

    Context 'Filename handling' {
        It 'sanitizes illegal filesystem characters in filename' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{ displayName = 'Test<Policy>Name:WithManyIllegalChars?'; value = 'test' }
            
            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder
                
                # verify the file was created without illegal chars
                $fileName = Split-Path -Path $result -Leaf
                $fileName | Should -Not -Match '[<>:""?*|/\\]'
                
                # verify content is there
                Get-Content $result | ConvertFrom-Json | Should -Not -BeNullOrEmpty
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        It 'uses displayName as default filename' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{ displayName = 'MyTestPolicy'; value = 'test' }
            
            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder
                
                $fileName = [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Path $result -Leaf))
                $fileName | Should -Be 'MyTestPolicy'
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        It 'uses FileName when PresetFileName is not provided' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{ displayName = 'Original'; value = 'test' }
            
            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder -FileName 'CustomName'
                
                $fileName = [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Path $result -Leaf))
                $fileName | Should -Be 'CustomName'
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        It 'uses PresetFileName when provided' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{ displayName = 'Original'; value = 'test' }
            
            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder -FileName 'Ignored' -PresetFileName 'Preset'
                
                $fileName = [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Path $result -Leaf))
                $fileName | Should -Be 'Preset'
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
    }

    Context 'JSON content validation' {
        It 'writes valid JSON to file' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{
                displayName = 'TestItem'
                settings    = [PSCustomObject]@{ key = 'value' }
                array       = @(1, 2, 3)
            }
            
            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder
                
                # read file and verify JSON
                $content = Get-Content -Path $result -Raw
                $parsed = ConvertFrom-Json -InputObject $content
                
                $parsed.displayName | Should -Be 'TestItem'
                $parsed.settings.key | Should -Be 'value'
                $parsed.array.Count | Should -Be 3
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        It 'orders JSON properties alphabetically including nested objects' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{
                zeta   = 'last'
                nested = [PSCustomObject]@{
                    zulu  = 'nested-last'
                    child = [PSCustomObject]@{
                        gamma = 3
                        alpha = 1
                        beta  = 2
                    }
                    alpha = 'nested-first'
                }
                alpha  = 'first'
            }

            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder -FileName 'OrderedJson'

                $content = Get-Content -Path $result -Raw
                $parsed = ConvertFrom-Json -InputObject $content -AsHashtable

                @($parsed.Keys) | Should -Be @('alpha', 'nested', 'zeta')
                @($parsed['nested'].Keys) | Should -Be @('alpha', 'child', 'zulu')
                @($parsed['nested']['child'].Keys) | Should -Be @('alpha', 'beta', 'gamma')
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
    }

    Context 'Scope tag resolution' {
        It 'translates roleScopeTagIds when ScopeTagMap is provided' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{
                displayName     = 'Policy'
                roleScopeTagIds = @('0', '1')
            }
            $map = @{ '0' = 'Default'; '1' = 'Finance' }

            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder -ScopeTagMap $map

                $parsed = Get-Content -Path $result -Raw | ConvertFrom-Json
                $parsed.roleScopeTagIds | Should -Contain 'Default'
                $parsed.roleScopeTagIds | Should -Contain 'Finance'
                $parsed.roleScopeTagIds | Should -Not -Contain '0'
                $parsed.roleScopeTagIds | Should -Not -Contain '1'
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        It 'does not translate roleScopeTagIds when ScopeTagMap is empty' {
            $testFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneCDTest_$(Get-Random)"
            $testItem = [PSCustomObject]@{
                displayName     = 'Policy'
                roleScopeTagIds = @('0')
            }

            try {
                $result = Save-BackupItem -Item $testItem -Folder $testFolder

                $parsed = Get-Content -Path $result -Raw | ConvertFrom-Json
                $parsed.roleScopeTagIds | Should -Contain '0'
            }
            finally {
                Remove-Item -Path $testFolder -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
    }
}


Describe 'Resolve-ScopeTagNames' {
    Context 'Scope tag ID replacement' {
        It 'replaces integer scope tag IDs with display names' {
            $scopeTagMap = @{
                '1' = 'Finance'
                '2' = 'HR'
            }
            
            $obj = [PSCustomObject]@{
                displayName      = 'Policy'
                roleScopeTagIds  = @(1, 2)
            }

            $result = $obj | Resolve-ScopeTagNames -ScopeTagMap $scopeTagMap
            
            $result.roleScopeTagIds | Should -Contain 'Finance'
            $result.roleScopeTagIds | Should -Contain 'HR'
            $result.roleScopeTagIds.Count | Should -Be 2
        }

        It 'leaves unknown IDs unchanged' {
            $scopeTagMap = @{
                '1' = 'Finance'
            }
            
            $obj = [PSCustomObject]@{
                displayName      = 'Policy'
                roleScopeTagIds  = @(1, 99)
            }

            $result = $obj | Resolve-ScopeTagNames -ScopeTagMap $scopeTagMap
            
            $result.roleScopeTagIds | Should -Contain 'Finance'
            $result.roleScopeTagIds | Should -Contain '99'
        }
    }

    Context 'Edge cases' {
        It 'handles object without roleScopeTagIds' {
            $scopeTagMap = @{ '1' = 'Finance' }
            
            $obj = [PSCustomObject]@{
                displayName = 'Policy'
                otherField  = 'value'
            }

            $result = $obj | Resolve-ScopeTagNames -ScopeTagMap $scopeTagMap
            
            $result.displayName | Should -Be 'Policy'
            $result.PSObject.Properties.Name | Should -Not -Contain 'roleScopeTagIds'
        }

        It 'handles null input' {
            $scopeTagMap = @{ '1' = 'Finance' }
            
            $result = $null | Resolve-ScopeTagNames -ScopeTagMap $scopeTagMap
            
            $result | Should -BeNullOrEmpty
        }

        It 'handles empty scope tag map' {
            $obj = [PSCustomObject]@{
                displayName     = 'Policy'
                roleScopeTagIds = @(1)
            }

            $result = $obj | Resolve-ScopeTagNames -ScopeTagMap @{}
            
            # should return unchanged
            $result.displayName | Should -Be 'Policy'
        }
    }
}