diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0667b69 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b7d0793 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +on: + push: + branches: + - main + +jobs: + build: + + runs-on: windows-2019 + + steps: + - uses: actions/checkout@v1 + - name: Install Prerequisites + run: .\build\vsts-prerequisites.ps1 + shell: powershell + - name: Validate + run: .\build\vsts-validate.ps1 + shell: powershell + - name: Build + run: .\build\vsts-build.ps1 -ApiKey $env:APIKEY + shell: powershell + env: + APIKEY: ${{ secrets.ApiKey }} \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..0b516ce --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,15 @@ +on: [pull_request] + +jobs: + validate: + + runs-on: windows-2019 + + steps: + - uses: actions/checkout@v1 + - name: Install Prerequisites + run: .\build\vsts-prerequisites.ps1 + shell: powershell + - name: Validate + run: .\build\vsts-validate.ps1 + shell: powershell \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f44c50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Pascal Haag + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ResolveEntraID/LICENSE b/ResolveEntraID/LICENSE new file mode 100644 index 0000000..7f44c50 --- /dev/null +++ b/ResolveEntraID/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Pascal Haag + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ResolveEntraID/ResolveEntraID.psd1 b/ResolveEntraID/ResolveEntraID.psd1 new file mode 100644 index 0000000..6097628 --- /dev/null +++ b/ResolveEntraID/ResolveEntraID.psd1 @@ -0,0 +1,129 @@ +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'ResolveEntraID.psm1' + +# Version number of this module. +ModuleVersion = '1.0.0' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '656edb76-7a06-4a6e-a844-d4ff075dceeb' + +# Author of this module +Author = 'Pascal Haag' + +# Company or vendor of this module +CompanyName = ' ' + +# Copyright statement for this module +Copyright = '(c) Pascal Haag. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Module to Resolve Entra IDs' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +RequiredModules = @( + 'MiniGraph' + 'PSFramework' +) + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = @( + +) + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +# CmdletsToExport = '*' + +# Variables to export from this module +# VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +# AliasesToExport = '*' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/ResolveEntraID/ResolveEntraID.psm1 b/ResolveEntraID/ResolveEntraID.psm1 new file mode 100644 index 0000000..2681965 --- /dev/null +++ b/ResolveEntraID/ResolveEntraID.psm1 @@ -0,0 +1,11 @@ +foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/functions" -Filter *.ps1 -Recurse) { + . $file.FullName +} + +foreach ($file in Get-ChildItem -Path "$PSScriptRoot/functions" -Filter *.ps1 -Recurse) { + . $file.FullName +} + +foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/scripts" -Filter *.ps1 -Recurse) { + . $file.FullName +} \ No newline at end of file diff --git a/ResolveEntraID/functions/Clear-MeidIdentityCache.ps1 b/ResolveEntraID/functions/Clear-MeidIdentityCache.ps1 new file mode 100644 index 0000000..29d91de --- /dev/null +++ b/ResolveEntraID/functions/Clear-MeidIdentityCache.ps1 @@ -0,0 +1,5 @@ +function Clear-MeidIdentityCache { + [CmdletBinding()] + param () + $script:IdNameMappingTable = @{} +} \ No newline at end of file diff --git a/ResolveEntraID/functions/Get-MeidIdentityProvider.ps1 b/ResolveEntraID/functions/Get-MeidIdentityProvider.ps1 new file mode 100644 index 0000000..36f2df2 --- /dev/null +++ b/ResolveEntraID/functions/Get-MeidIdentityProvider.ps1 @@ -0,0 +1,10 @@ +function Get-MeidIdentityProvider { + [CmdletBinding()] + param ( + [string] + $Name = '*' + ) + process { + $script:IdentityProvider.Values | Where-Object Name -like $Name + } +} \ No newline at end of file diff --git a/ResolveEntraID/functions/Register-MeidIdentityProvider.ps1 b/ResolveEntraID/functions/Register-MeidIdentityProvider.ps1 new file mode 100644 index 0000000..2ffd703 --- /dev/null +++ b/ResolveEntraID/functions/Register-MeidIdentityProvider.ps1 @@ -0,0 +1,29 @@ +function Register-MeidIdentityProvider { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string[]] + $Name, + + [Parameter(Mandatory)] + [string[]] + $NameProperty, + + [Parameter(Mandatory)] + [string[]] + $Query + ) + process { + $queries = foreach ($item in $Query){ + if($item -match "\{0\}"){ $item; continue} + $item.TrimEnd("/").Replace("{","{{").Replace("}","}}"), "{0}" -join "/" + } + foreach ($entry in $Name) { + $script:IdentityProvider[$entry] = [PSCustomObject]@{ + Name = $entry + NameProperty = $NameProperty + Query = $queries + } + } + } +} \ No newline at end of file diff --git a/ResolveEntraID/functions/Resolve-MeidIdentity.ps1 b/ResolveEntraID/functions/Resolve-MeidIdentity.ps1 new file mode 100644 index 0000000..76558c3 --- /dev/null +++ b/ResolveEntraID/functions/Resolve-MeidIdentity.ps1 @@ -0,0 +1,65 @@ +function Resolve-MeidIdentity { + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string[]] + $Id, + + [Parameter(Mandatory)] + [string[]] + $Provider, + + [switch] + $NoCache, + + [switch] + $NameOnly + ) + begin { + function Write-Result { + [CmdletBinding()] + param ( + [string] + $Id, + + [string] + $Provider, + + [string] + $Value, + + [switch] + $NameOnly, + + [switch] + $NoCache + ) + $result = [PSCustomObject]@{ + ID = $Id + Name = $Value + Provider = $Provider + } + if ($NameOnly) { $Value } + else { $result } + if(-not $NoCache) { $script:IdNameMappingTable[$Id] = $result} + } + } + process { + foreach ($entry in $Id) { + if ($entry -notmatch '^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$') { + $entry + continue + } + if ($script:IdNameMappingTable[$entry] -and -not $NoCache) { + Write-Result -Id $entry -Value $script:IdNameMappingTable[$entry].Name -NoCache -Provider $script:IdNameMappingTable[$entry].Provider + continue + } + foreach ($providerName in $Provider){ + + } + } + } + end { + + } +} \ No newline at end of file diff --git a/ResolveEntraID/functions/readme.md b/ResolveEntraID/functions/readme.md new file mode 100644 index 0000000..105038b --- /dev/null +++ b/ResolveEntraID/functions/readme.md @@ -0,0 +1,3 @@ +# Functions + +Folder for all the functions the user is supposed to have access to. diff --git a/ResolveEntraID/internal/functions/readme.md b/ResolveEntraID/internal/functions/readme.md new file mode 100644 index 0000000..0643487 --- /dev/null +++ b/ResolveEntraID/internal/functions/readme.md @@ -0,0 +1,3 @@ +# Internal > Functions + +Folder for all the functions you want the user to not see. diff --git a/ResolveEntraID/internal/scripts/readme.md b/ResolveEntraID/internal/scripts/readme.md new file mode 100644 index 0000000..2a21053 --- /dev/null +++ b/ResolveEntraID/internal/scripts/readme.md @@ -0,0 +1,3 @@ +# Internal > Scripts + +Put in all the scripts that should be run once during import diff --git a/ResolveEntraID/internal/scripts/variables.ps1 b/ResolveEntraID/internal/scripts/variables.ps1 new file mode 100644 index 0000000..f2b7e12 --- /dev/null +++ b/ResolveEntraID/internal/scripts/variables.ps1 @@ -0,0 +1,5 @@ +# ID for Name mapping +$script:IdNameMappingTable = @{} + +# Identity Provider +$script:IdentityProvider = @{} \ No newline at end of file diff --git a/build/vsts-build.ps1 b/build/vsts-build.ps1 new file mode 100644 index 0000000..c0a2a07 --- /dev/null +++ b/build/vsts-build.ps1 @@ -0,0 +1,98 @@ +<# +This script publishes the module to the gallery. +It expects as input an ApiKey authorized to publish the module. + +Insert any build steps you may need to take before publishing it here. +#> +param ( + $ApiKey, + + $WorkingDirectory, + + $Repository = 'PSGallery', + + [switch] + $LocalRepo, + + [switch] + $SkipPublish, + + [switch] + $AutoVersion +) + +#region Handle Working Directory Defaults +if (-not $WorkingDirectory) +{ + if ($env:RELEASE_PRIMARYARTIFACTSOURCEALIAS) + { + $WorkingDirectory = Join-Path -Path $env:SYSTEM_DEFAULTWORKINGDIRECTORY -ChildPath $env:RELEASE_PRIMARYARTIFACTSOURCEALIAS + } + else { $WorkingDirectory = $env:SYSTEM_DEFAULTWORKINGDIRECTORY } +} +if (-not $WorkingDirectory) { $WorkingDirectory = Split-Path $PSScriptRoot } +#endregion Handle Working Directory Defaults + +# Prepare publish folder +Write-Host "Creating and populating publishing directory" +$publishDir = New-Item -Path $WorkingDirectory -Name publish -ItemType Directory -Force +Copy-Item -Path "$($WorkingDirectory)\ResolveEntraID" -Destination $publishDir.FullName -Recurse -Force + +#region Gather text data to compile +$text = @() + +# Gather commands +Get-ChildItem -Path "$($publishDir.FullName)\ResolveEntraID\internal\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) +} +Get-ChildItem -Path "$($publishDir.FullName)\ResolveEntraID\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) +} + +# Gather scripts +Get-ChildItem -Path "$($publishDir.FullName)\ResolveEntraID\internal\scripts\" -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) +} + +#region Update the psm1 file & Cleanup +[System.IO.File]::WriteAllText("$($publishDir.FullName)\ResolveEntraID\ResolveEntraID.psm1", ($text -join "`n`n"), [System.Text.Encoding]::UTF8) +Remove-Item -Path "$($publishDir.FullName)\ResolveEntraID\internal" -Recurse -Force +Remove-Item -Path "$($publishDir.FullName)\ResolveEntraID\functions" -Recurse -Force +#endregion Update the psm1 file & Cleanup + +#region Updating the Module Version +if ($AutoVersion) +{ + Write-Host "Updating module version numbers." + try { [version]$remoteVersion = (Find-Module 'ResolveEntraID' -Repository $Repository -ErrorAction Stop).Version } + catch + { + throw "Failed to access $($Repository) : $_" + } + if (-not $remoteVersion) + { + throw "Couldn't find ResolveEntraID on repository $($Repository) : $_" + } + $newBuildNumber = $remoteVersion.Build + 1 + [version]$localVersion = (Import-PowerShellDataFile -Path "$($publishDir.FullName)\ResolveEntraID\ResolveEntraID.psd1").ModuleVersion + Update-ModuleManifest -Path "$($publishDir.FullName)\ResolveEntraID\ResolveEntraID.psd1" -ModuleVersion "$($localVersion.Major).$($localVersion.Minor).$($newBuildNumber)" +} +#endregion Updating the Module Version + +#region Publish +if ($SkipPublish) { return } +if ($LocalRepo) +{ + # Dependencies must go first + Write-Host "Creating Nuget Package for module: PSFramework" + New-PSMDModuleNugetPackage -ModulePath (Get-Module -Name PSFramework).ModuleBase -PackagePath . + Write-Host "Creating Nuget Package for module: ResolveEntraID" + New-PSMDModuleNugetPackage -ModulePath "$($publishDir.FullName)\ResolveEntraID" -PackagePath . +} +else +{ + # Publish to Gallery + Write-Host "Publishing the ResolveEntraID module to $($Repository)" + Publish-Module -Path "$($publishDir.FullName)\ResolveEntraID" -NuGetApiKey $ApiKey -Force -Repository $Repository +} +#endregion Publish \ No newline at end of file diff --git a/build/vsts-prerequisites.ps1 b/build/vsts-prerequisites.ps1 new file mode 100644 index 0000000..6485fd9 --- /dev/null +++ b/build/vsts-prerequisites.ps1 @@ -0,0 +1,25 @@ +param ( + [string] + $Repository = 'PSGallery' +) + +$modules = @("Pester", "PSScriptAnalyzer") + +# Automatically add missing dependencies +$data = Import-PowerShellDataFile -Path "$PSScriptRoot\..\ResolveEntraID\ResolveEntraID.psd1" +foreach ($dependency in $data.RequiredModules) { + if ($dependency -is [string]) { + if ($modules -contains $dependency) { continue } + $modules += $dependency + } + else { + if ($modules -contains $dependency.ModuleName) { continue } + $modules += $dependency.ModuleName + } +} + +foreach ($module in $modules) { + Write-Host "Installing $module" -ForegroundColor Cyan + Install-Module $module -Force -SkipPublisherCheck -Repository $Repository + Import-Module $module -Force -PassThru +} \ No newline at end of file diff --git a/build/vsts-validate.ps1 b/build/vsts-validate.ps1 new file mode 100644 index 0000000..1ad4c70 --- /dev/null +++ b/build/vsts-validate.ps1 @@ -0,0 +1,2 @@ +# Run internal pester tests +& "$PSScriptRoot\..\tests\pester.ps1" \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..54cd1e8 --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# ResolveEntraID + +ADD DESCRIPTION HERE diff --git a/tests/functions/readme.md b/tests/functions/readme.md new file mode 100644 index 0000000..f2b2ef0 --- /dev/null +++ b/tests/functions/readme.md @@ -0,0 +1,7 @@ +# Description + +This is where the function tests go. + +Make sure to put them in folders reflecting the actual module structure. + +It is not necessary to differentiate between internal and public functions here. \ No newline at end of file diff --git a/tests/general/FileIntegrity.Exceptions.ps1 b/tests/general/FileIntegrity.Exceptions.ps1 new file mode 100644 index 0000000..0d92e79 --- /dev/null +++ b/tests/general/FileIntegrity.Exceptions.ps1 @@ -0,0 +1,31 @@ +# List of forbidden commands +$global:BannedCommands = @( + 'Write-Output' + + # Use CIM instead where possible + 'Get-WmiObject' + 'Invoke-WmiMethod' + 'Register-WmiEvent' + 'Remove-WmiObject' + 'Set-WmiInstance' + + # Use Get-WinEvent instead + 'Get-EventLog' +) + +<# + Contains list of exceptions for banned cmdlets. + Insert the file names of files that may contain them. + + Example: + "Write-Host" = @('Write-PSFHostColor.ps1','Write-PSFMessage.ps1') +#> +$global:MayContainCommand = @{ + "Write-Host" = @() + "Write-Verbose" = @() + "Write-Warning" = @() + "Write-Error" = @() + "Write-Output" = @() + "Write-Information" = @() + "Write-Debug" = @() +} \ No newline at end of file diff --git a/tests/general/FileIntegrity.Tests.ps1 b/tests/general/FileIntegrity.Tests.ps1 new file mode 100644 index 0000000..8656e65 --- /dev/null +++ b/tests/general/FileIntegrity.Tests.ps1 @@ -0,0 +1,95 @@ +$moduleRoot = (Resolve-Path "$global:testroot\..").Path + +. "$global:testroot\general\FileIntegrity.Exceptions.ps1" + +Describe "Verifying integrity of module files" { + BeforeAll { + function Get-FileEncoding + { + <# + .SYNOPSIS + Tests a file for encoding. + + .DESCRIPTION + Tests a file for encoding. + + .PARAMETER Path + The file to test + #> + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)] + [Alias('FullName')] + [string] + $Path + ) + + if ($PSVersionTable.PSVersion.Major -lt 6) + { + [byte[]]$byte = get-content -Encoding byte -ReadCount 4 -TotalCount 4 -Path $Path + } + else + { + [byte[]]$byte = Get-Content -AsByteStream -ReadCount 4 -TotalCount 4 -Path $Path + } + + if ($byte[0] -eq 0xef -and $byte[1] -eq 0xbb -and $byte[2] -eq 0xbf) { 'UTF8 BOM' } + elseif ($byte[0] -eq 0xfe -and $byte[1] -eq 0xff) { 'Unicode' } + elseif ($byte[0] -eq 0 -and $byte[1] -eq 0 -and $byte[2] -eq 0xfe -and $byte[3] -eq 0xff) { 'UTF32' } + elseif ($byte[0] -eq 0x2b -and $byte[1] -eq 0x2f -and $byte[2] -eq 0x76) { 'UTF7' } + else { 'Unknown' } + } + } + + Context "Validating PS1 Script files" { + $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.ps1" | Where-Object FullName -NotLike "$moduleRoot\tests\*" + + foreach ($file in $allFiles) + { + $name = $file.FullName.Replace("$moduleRoot\", '') + + It "[$name] Should have UTF8 encoding with Byte Order Mark" -TestCases @{ file = $file } { + Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' + } + + It "[$name] Should have no trailing space" -TestCases @{ file = $file } { + ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0}).LineNumber | Should -BeNullOrEmpty + } + + $tokens = $null + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors) + + It "[$name] Should have no syntax errors" -TestCases @{ parseErrors = $parseErrors } { + $parseErrors | Should -BeNullOrEmpty + } + + foreach ($command in $global:BannedCommands) + { + if ($global:MayContainCommand["$command"] -notcontains $file.Name) + { + It "[$name] Should not use $command" -TestCases @{ tokens = $tokens; command = $command } { + $tokens | Where-Object Text -EQ $command | Should -BeNullOrEmpty + } + } + } + } + } + + Context "Validating help.txt help files" { + $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.help.txt" | Where-Object FullName -NotLike "$moduleRoot\tests\*" + + foreach ($file in $allFiles) + { + $name = $file.FullName.Replace("$moduleRoot\", '') + + It "[$name] Should have UTF8 encoding" -TestCases @{ file = $file } { + Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' + } + + It "[$name] Should have no trailing space" -TestCases @{ file = $file } { + ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0 } | Measure-Object).Count | Should -Be 0 + } + } + } +} \ No newline at end of file diff --git a/tests/general/Help.Exceptions.ps1 b/tests/general/Help.Exceptions.ps1 new file mode 100644 index 0000000..f9c9bd7 --- /dev/null +++ b/tests/general/Help.Exceptions.ps1 @@ -0,0 +1,26 @@ +# List of functions that should be ignored +$global:FunctionHelpTestExceptions = @( + +) + +<# + List of arrayed enumerations. These need to be treated differently. Add full name. + Example: + + "Sqlcollaborative.Dbatools.Connection.ManagementConnectionType[]" +#> +$global:HelpTestEnumeratedArrays = @( + +) + +<# + Some types on parameters just fail their validation no matter what. + For those it becomes possible to skip them, by adding them to this hashtable. + Add by following this convention: = @() + Example: + + "Get-DbaCmObject" = @("DoNotUse") +#> +$global:HelpTestSkipParameterType = @{ + +} diff --git a/tests/general/Help.Tests.ps1 b/tests/general/Help.Tests.ps1 new file mode 100644 index 0000000..01d7e7f --- /dev/null +++ b/tests/general/Help.Tests.ps1 @@ -0,0 +1,152 @@ +<# + .NOTES + The original test this is based upon was written by June Blender. + After several rounds of modifications it stands now as it is, but the honor remains hers. + + Thank you June, for all you have done! + + .DESCRIPTION + This test evaluates the help for all commands in a module. + + .PARAMETER SkipTest + Disables this test. + + .PARAMETER CommandPath + List of paths under which the script files are stored. + This test assumes that all functions have their own file that is named after themselves. + These paths are used to search for commands that should exist and be tested. + Will search recursively and accepts wildcards, make sure only functions are found + + .PARAMETER ModuleName + Name of the module to be tested. + The module must already be imported + + .PARAMETER ExceptionsFile + File in which exceptions and adjustments are configured. + In it there should be two arrays and a hashtable defined: + $global:FunctionHelpTestExceptions + $global:HelpTestEnumeratedArrays + $global:HelpTestSkipParameterType + These can be used to tweak the tests slightly in cases of need. + See the example file for explanations on each of these usage and effect. +#> +[CmdletBinding()] +Param ( + [switch] + $SkipTest, + + [string[]] + $CommandPath = @("$global:testroot\..\ResolveEntraID\functions", "$global:testroot\..\ResolveEntraID\internal\functions"), + + [string] + $ModuleName = "ResolveEntraID", + + [string] + $ExceptionsFile = "$global:testroot\general\Help.Exceptions.ps1" +) +if ($SkipTest) { return } +. $ExceptionsFile + +$CommandPath = @( + "$global:testroot\..\ResolveEntraID\functions" + "$global:testroot\..\ResolveEntraID\internal\functions" +) + +$includedNames = foreach ($path in $CommandPath) { (Get-ChildItem $path -Recurse -File | Where-Object Name -like "*.ps1").BaseName } +$commandTypes = @('Cmdlet', 'Function') +if ($PSVersionTable.PSEdition -eq 'Desktop' ) { $commandTypes += 'Workflow' } +$commands = Get-Command -Module (Get-Module $ModuleName) -CommandType $commandTypes | Where-Object Name -In $includedNames + +## When testing help, remember that help is cached at the beginning of each session. +## To test, restart session. + + +foreach ($command in $commands) { + $commandName = $command.Name + + # Skip all functions that are on the exclusions list + if ($global:FunctionHelpTestExceptions -contains $commandName) { continue } + + # The module-qualified command fails on Microsoft.PowerShell.Archive cmdlets + $Help = Get-Help $commandName -ErrorAction SilentlyContinue + + Describe "Test help for $commandName" { + + # If help is not found, synopsis in auto-generated help is the syntax diagram + It "should not be auto-generated" -TestCases @{ Help = $Help } { + $Help.Synopsis | Should -Not -BeLike '*`[``]*' + } + + # Should be a description for every function + It "gets description for $commandName" -TestCases @{ Help = $Help } { + $Help.Description | Should -Not -BeNullOrEmpty + } + + # Should be at least one example + It "gets example code from $commandName" -TestCases @{ Help = $Help } { + ($Help.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty + } + + # Should be at least one example description + It "gets example help from $commandName" -TestCases @{ Help = $Help } { + ($Help.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty + } + + Context "Test parameter help for $commandName" { + + $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable' + + $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common + $parameterNames = $parameters.Name + $HelpParameterNames = $Help.Parameters.Parameter.Name | Sort-Object -Unique + foreach ($parameter in $parameters) { + $parameterName = $parameter.Name + $parameterHelp = $Help.parameters.parameter | Where-Object Name -EQ $parameterName + + # Should be a description for every parameter + It "gets help for parameter: $parameterName : in $commandName" -TestCases @{ parameterHelp = $parameterHelp } { + $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty + } + + $codeMandatory = $parameter.IsMandatory.toString() + It "help for $parameterName parameter in $commandName has correct Mandatory value" -TestCases @{ parameterHelp = $parameterHelp; codeMandatory = $codeMandatory } { + $parameterHelp.Required | Should -Be $codeMandatory + } + + if ($HelpTestSkipParameterType[$commandName] -contains $parameterName) { continue } + + $codeType = $parameter.ParameterType.Name + + if ($parameter.ParameterType.IsEnum) { + # Enumerations often have issues with the typename not being reliably available + $names = $parameter.ParameterType::GetNames($parameter.ParameterType) + # Parameter type in Help should match code + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { + $parameterHelp.parameterValueGroup.parameterValue | Should -be $names + } + } + elseif ($parameter.ParameterType.FullName -in $HelpTestEnumeratedArrays) { + # Enumerations often have issues with the typename not being reliably available + $names = [Enum]::GetNames($parameter.ParameterType.DeclaredMembers[0].ReturnType) + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { + $parameterHelp.parameterValueGroup.parameterValue | Should -be $names + } + } + else { + # To avoid calling Trim method on a null object. + $helpType = if ($parameterHelp.parameterValue) { $parameterHelp.parameterValue.Trim() } + # Parameter type in Help should match code + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ helpType = $helpType; codeType = $codeType } { + $helpType | Should -be $codeType + } + } + } + foreach ($helpParm in $HelpParameterNames) { + # Shouldn't find extra parameters in help. + It "finds help parameter in code: $helpParm" -TestCases @{ helpParm = $helpParm; parameterNames = $parameterNames } { + $helpParm -in $parameterNames | Should -Be $true + } + } + } + } +} \ No newline at end of file diff --git a/tests/general/Manifest.Tests.ps1 b/tests/general/Manifest.Tests.ps1 new file mode 100644 index 0000000..ea14b46 --- /dev/null +++ b/tests/general/Manifest.Tests.ps1 @@ -0,0 +1,62 @@ +Describe "Validating the module manifest" { + $moduleRoot = (Resolve-Path "$global:testroot\..\ResolveEntraID").Path + $manifest = ((Get-Content "$moduleRoot\ResolveEntraID.psd1") -join "`n") | Invoke-Expression + Context "Basic resources validation" { + $files = Get-ChildItem "$moduleRoot\functions" -Recurse -File | Where-Object Name -like "*.ps1" + It "Exports all functions in the public folder" -TestCases @{ files = $files; manifest = $manifest } { + + $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '<=').InputObject + $functions | Should -BeNullOrEmpty + } + It "Exports no function that isn't also present in the public folder" -TestCases @{ files = $files; manifest = $manifest } { + $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '=>').InputObject + $functions | Should -BeNullOrEmpty + } + + It "Exports none of its internal functions" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { + $files = Get-ChildItem "$moduleRoot\internal\functions" -Recurse -File -Filter "*.ps1" + $files | Where-Object BaseName -In $manifest.FunctionsToExport | Should -BeNullOrEmpty + } + } + + Context "Individual file validation" { + It "The root module file exists" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { + Test-Path "$moduleRoot\$($manifest.RootModule)" | Should -Be $true + } + + foreach ($format in $manifest.FormatsToProcess) + { + It "The file $format should exist" -TestCases @{ moduleRoot = $moduleRoot; format = $format } { + Test-Path "$moduleRoot\$format" | Should -Be $true + } + } + + foreach ($type in $manifest.TypesToProcess) + { + It "The file $type should exist" -TestCases @{ moduleRoot = $moduleRoot; type = $type } { + Test-Path "$moduleRoot\$type" | Should -Be $true + } + } + + foreach ($assembly in $manifest.RequiredAssemblies) + { + if ($assembly -like "*.dll") { + It "The file $assembly should exist" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { + Test-Path "$moduleRoot\$assembly" | Should -Be $true + } + } + else { + It "The file $assembly should load from the GAC" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { + { Add-Type -AssemblyName $assembly } | Should -Not -Throw + } + } + } + + foreach ($tag in $manifest.PrivateData.PSData.Tags) + { + It "Tags should have no spaces in name" -TestCases @{ tag = $tag } { + $tag -match " " | Should -Be $false + } + } + } +} \ No newline at end of file diff --git a/tests/general/PSScriptAnalyzer.Tests.ps1 b/tests/general/PSScriptAnalyzer.Tests.ps1 new file mode 100644 index 0000000..eb76b0f --- /dev/null +++ b/tests/general/PSScriptAnalyzer.Tests.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +Param ( + [switch] + $SkipTest, + + [string[]] + $CommandPath = @("$global:testroot\..\ResolveEntraID\functions", "$global:testroot\..\ResolveEntraID\internal\functions") +) + +if ($SkipTest) { return } + +$global:__pester_data.ScriptAnalyzer = New-Object System.Collections.ArrayList + +Describe 'Invoking PSScriptAnalyzer against commandbase' { + $commandFiles = foreach ($path in $CommandPath) { Get-ChildItem -Path $path -Recurse | Where-Object Name -like "*.ps1" } + $scriptAnalyzerRules = Get-ScriptAnalyzerRule + + foreach ($file in $commandFiles) + { + Context "Analyzing $($file.BaseName)" { + $analysis = Invoke-ScriptAnalyzer -Path $file.FullName -ExcludeRule PSAvoidTrailingWhitespace, PSShouldProcess + + forEach ($rule in $scriptAnalyzerRules) + { + It "Should pass $rule" -TestCases @{ analysis = $analysis; rule = $rule } { + If ($analysis.RuleName -contains $rule) + { + $analysis | Where-Object RuleName -EQ $rule -outvariable failures | ForEach-Object { $null = $global:__pester_data.ScriptAnalyzer.Add($_) } + + 1 | Should -Be 0 + } + else + { + 0 | Should -Be 0 + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/pester.ps1 b/tests/pester.ps1 new file mode 100644 index 0000000..e1219c0 --- /dev/null +++ b/tests/pester.ps1 @@ -0,0 +1,113 @@ +param ( + $TestGeneral = $true, + + $TestFunctions = $true, + + [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] + [Alias('Show')] + $Output = "None", + + $Include = "*", + + $Exclude = "" +) + +Write-Host "Starting Tests" + +Write-Host "Importing Module" + +$global:testroot = $PSScriptRoot +$global:__pester_data = @{ } + +Remove-Module ResolveEntraID -ErrorAction Ignore +Import-Module "$PSScriptRoot\..\ResolveEntraID\ResolveEntraID.psd1" +Import-Module "$PSScriptRoot\..\ResolveEntraID\ResolveEntraID.psm1" -Force + +# Need to import explicitly so we can use the configuration class +Import-Module Pester + +Write-Host "Creating test result folder" +$null = New-Item -Path "$PSScriptRoot\.." -Name TestResults -ItemType Directory -Force + +$totalFailed = 0 +$totalRun = 0 + +$testresults = @() +$config = [PesterConfiguration]::Default +$config.TestResult.Enabled = $true + +#region Run General Tests +if ($TestGeneral) +{ + Write-Host "Modules imported, proceeding with general tests" + foreach ($file in (Get-ChildItem "$PSScriptRoot\general" | Where-Object Name -like "*.Tests.ps1")) + { + if ($file.Name -notlike $Include) { continue } + if ($file.Name -like $Exclude) { continue } + + Write-Host " Executing $($file.Name)" + $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\TestResults" "TEST-$($file.BaseName).xml" + $config.Run.Path = $file.FullName + $config.Run.PassThru = $true + $config.Output.Verbosity = $Output + $results = Invoke-Pester -Configuration $config + foreach ($result in $results) + { + $totalRun += $result.TotalCount + $totalFailed += $result.FailedCount + $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { + $testresults += [pscustomobject]@{ + Block = $_.Block + Name = "It $($_.Name)" + Result = $_.Result + Message = $_.ErrorRecord.DisplayErrorMessage + } + } + } + } +} +#endregion Run General Tests + +$global:__pester_data.ScriptAnalyzer | Out-Host + +#region Test Commands +if ($TestFunctions) +{ + Write-Host "Proceeding with individual tests" + foreach ($file in (Get-ChildItem "$PSScriptRoot\functions" -Recurse -File | Where-Object Name -like "*Tests.ps1")) + { + if ($file.Name -notlike $Include) { continue } + if ($file.Name -like $Exclude) { continue } + + Write-Host " Executing $($file.Name)" + $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\TestResults" "TEST-$($file.BaseName).xml" + $config.Run.Path = $file.FullName + $config.Run.PassThru = $true + $config.Output.Verbosity = $Output + $results = Invoke-Pester -Configuration $config + foreach ($result in $results) + { + $totalRun += $result.TotalCount + $totalFailed += $result.FailedCount + $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { + $testresults += [pscustomobject]@{ + Block = $_.Block + Name = "It $($_.Name)" + Result = $_.Result + Message = $_.ErrorRecord.DisplayErrorMessage + } + } + } + } +} +#endregion Test Commands + +$testresults | Sort-Object Describe, Context, Name, Result, Message | Format-List + +if ($totalFailed -eq 0) { Write-Host "All $totalRun tests executed without a single failure!" } +else { Write-Host "$totalFailed tests out of $totalRun tests failed!" } + +if ($totalFailed -gt 0) +{ + throw "$totalFailed / $totalRun tests failed!" +} \ No newline at end of file diff --git a/tests/readme.md b/tests/readme.md new file mode 100644 index 0000000..43bb2fa --- /dev/null +++ b/tests/readme.md @@ -0,0 +1,31 @@ +# Description + +This is the folder, where all the tests go. + +Those are subdivided in two categories: + + - General + - Function + +## General Tests + +General tests are function generic and test for general policies. + +These test scan answer questions such as: + + - Is my module following my style guides? + - Does any of my scripts have a syntax error? + - Do my scripts use commands I do not want them to use? + - Do my commands follow best practices? + - Do my commands have proper help? + +Basically, these allow a general module health check. + +These tests are already provided as part of the template. + +## Function Tests + +A healthy module should provide unit and integration tests for the commands & components it ships. +Only then can be guaranteed, that they will actually perform as promised. + +However, as each such test must be specific to the function it tests, there cannot be much in the way of templates. \ No newline at end of file