TestEnvironment.psm1

#Requires -Version 5.1

# TestEnvironment PowerShell Module
#
# One module, several identity providers. The shared concerns - SecretStore, password
# generation, certificate handling, progress, base64url - live in Core and have exactly one
# implementation. Everything that genuinely differs between an on-premises directory, Okta and
# Entra lives in its own provider.
#
# The dependency stance is what makes this possible. RequiredModules is empty and a contract
# test enforces it, so importing this module installs and requires nothing. The AD provider
# needs RSAT's ActiveDirectory and GroupPolicy modules, and imports them LAZILY at connect time
# rather than declaring them - otherwise a Windows-only, RSAT-only dependency would be imposed
# on somebody who only wanted to seed an Okta org from a Linux container.

$ModuleRoot = $PSScriptRoot
Write-Verbose "Initializing TestEnvironment module from: $ModuleRoot"

# The naming prefix every provider stamps on everything it creates except human user accounts,
# and the root the seed tag is derived from. Defined once here rather than three times in three
# Connect functions, because a prefix that differs by provider is a prefix that cannot be used
# to find this module's objects across a hybrid estate.
#
# ZZ- leads so that seeded objects sort to the bottom of an alphabetical console listing, out of
# the way in a directory that also holds real work. No asterisks or other wildcard characters:
# they have to be escaped in an LDAP distinguished name and are rejected outright in an Entra
# group's mailNickname.
$script:TestEnvironmentDefaultPrefix = 'ZZ-TEST-'

# --- Core -------------------------------------------------------------------------------
# Loaded first, because providers call into it.
foreach ($function in (Get-ChildItem -Path "$ModuleRoot\Core\*.ps1" -ErrorAction SilentlyContinue)) {
    Write-Verbose "Loading core function: $($function.Name)"
    . $function.FullName
}

# --- Providers --------------------------------------------------------------------------
# Every provider is dot-sourced at import. That costs nothing: a provider's functions only
# reference their platform's cmdlets when actually invoked, so loading the AD provider on a
# host with no RSAT is harmless right up until somebody connects to a domain.
$script:TestEnvironmentProvider = @{}

foreach ($providerFolder in (Get-ChildItem -Path "$ModuleRoot\Providers" -Directory -ErrorAction SilentlyContinue)) {
    $providerName = $providerFolder.Name
    Write-Verbose "Loading provider: $providerName"

    foreach ($subFolder in 'Private', 'Public') {
        foreach ($function in (Get-ChildItem -Path "$($providerFolder.FullName)\$subFolder\*.ps1" -ErrorAction SilentlyContinue)) {
            . $function.FullName
        }
    }

    # A provider may ship module-scope constants its functions read. They are dot-sourced last,
    # and the file is optional. This exists because the Okta provider's seed domain lived in its
    # old root module and was lost in the move - nothing failed at import, and the -replace that
    # used it silently matched an empty pattern instead. A contract test now proves every
    # script-scope variable a provider reads is assigned somewhere.
    $providerInit = Join-Path $providerFolder.FullName 'Initialize.ps1'
    if (Test-Path -LiteralPath $providerInit) {
        Write-Verbose "Loading provider constants: $providerName"
        . $providerInit
    }

    $script:TestEnvironmentProvider[$providerName] = @{
        Name     = $providerName
        Root     = $providerFolder.FullName
        DataPath = Join-Path $providerFolder.FullName 'Data'
    }
}

# --- Shared dispatch --------------------------------------------------------------------
foreach ($function in (Get-ChildItem -Path "$ModuleRoot\Public\*.ps1" -ErrorAction SilentlyContinue)) {
    Write-Verbose "Loading public function: $($function.Name)"
    . $function.FullName
}

# --- Module state -----------------------------------------------------------------------
# Which provider the session is connected through. Set by Connect-TestEnvironment and read by
# every dispatching function, so the provider is named once rather than on every call.
$script:ActiveProvider = $null

Export-ModuleMember -Function @(
    # Shared, provider-agnostic
    'Connect-TestEnvironment',
    'Disconnect-TestEnvironment',
    'Get-TestEnvironmentProvider',
    'New-TestEnvironment',
    'Remove-TestEnvironment',
    'Get-TestEnvironmentReport',
    'Get-TestAccessToken',
    'New-TestServiceApp',
    'Get-TestServiceApp',
    'Update-TestContainment',

    # Entra provider components. Exposed under their own names because the objects they create
    # genuinely have no counterpart in the other providers - an administrative unit is not an
    # OU and a Conditional Access policy is not an Okta sign-on policy. Pretending otherwise
    # behind a shared noun would be a worse abstraction than naming them honestly.
    'New-EntraAdministrativeUnit',
    'New-EntraUser',
    'New-EntraGuestUser',
    'New-EntraGroup',
    'New-EntraDevice',
    'New-EntraApplication',
    'New-EntraNamedLocation',
    'New-EntraConditionalAccessPolicy',
    'New-EntraAuthenticationStrength',
    'New-EntraDirectoryRole',
    'New-EntraRoleEligibility',
    'New-EntraDirectoryExtension',
    'Set-EntraLicense',

    # Active Directory provider components. The Test infix survives here where Entra's did
    # not, because New-ADUser, New-ADGroup and Get-ADDomain are real cmdlets from the RSAT
    # module this provider imports, and shadowing one would be found ahead of the real
    # cmdlet by everything else in the session.
    'New-ADTestOUStructure',
    'New-ADTestUser',
    'New-ADTestDevice',
    'New-ADTestSecurityGroups',
    'New-ADTestServiceAccount',
    'New-ADTestEdgeCase',
    'New-ADTestGroupPolicy',
    'Get-ADTestPasswordFromVault',

    # Okta provider components. These drop the Test infix as the Entra ones did, because Okta
    # ships no PowerShell cmdlets of its own for them to collide with.
    'New-OktaProfileAttribute',
    'New-OktaUser',
    'New-OktaGroup',
    'New-OktaGroupRule',
    'New-OktaApp',
    'New-OktaUserType',
    'New-OktaNetworkZone',
    'New-OktaPolicy',
    'New-OktaLinkedObject',
    'New-OktaTrustedOrigin',
    'New-OktaEventHook',

    # Authentik provider components. No Test infix, for the same reason as Entra and Okta:
    # Authentik ships no PowerShell cmdlets to collide with.
    'New-AuthentikGroup',
    'New-AuthentikUser',
    'New-AuthentikRole',
    'New-AuthentikApplication',
    'New-AuthentikOutpost',
    'New-AuthentikFlow',
    'New-AuthentikScopeMapping',
    'New-AuthentikEntitlement',
    'New-AuthentikPolicy',
    'New-AuthentikNotificationRule',
    'New-AuthentikBinding',
    'New-AuthentikToken',
    'New-AuthentikInvitation',

    # FreeIPA provider components. No Test infix, for the same reason as Entra and Okta: FreeIPA
    # ships no PowerShell cmdlets of its own for them to collide with.
    'New-FreeIPAGroup',
    'New-FreeIPAUser',
    'New-FreeIPAHostgroup',
    'New-FreeIPAHost',
    'New-FreeIPANetgroup',
    'New-FreeIPAHbacRule',
    'New-FreeIPASudoRule',
    'New-FreeIPARole',
    'New-FreeIPAPasswordPolicy',
    'New-FreeIPAService',
    'New-FreeIPAIdView',
    'New-FreeIPAOtpToken',
    'New-FreeIPAAutomemberRule',
    'New-FreeIPAAutomount',
    'New-FreeIPASelinuxUserMap',
    'New-FreeIPACertMapRule',
    'New-FreeIPACaAcl',
    'New-FreeIPACertificate',
    'New-FreeIPADnsZone',
    'New-FreeIPAIdentityProvider'
)

$ExecutionContext.SessionState.Module.OnRemove = {
    Write-Verbose "Cleaning up TestEnvironment module"
    Remove-Variable -Name EntraConnection -Scope Script -ErrorAction SilentlyContinue
    Remove-Variable -Name ADConnection -Scope Script -ErrorAction SilentlyContinue
    Remove-Variable -Name OktaConnection -Scope Script -ErrorAction SilentlyContinue
    Remove-Variable -Name AuthentikConnection -Scope Script -ErrorAction SilentlyContinue
    Remove-Variable -Name ActiveProvider -Scope Script -ErrorAction SilentlyContinue
    Remove-Variable -Name TestEnvironmentProvider -Scope Script -ErrorAction SilentlyContinue
}