Providers/Entra/Public/New-EntraUser.ps1

function New-EntraUser {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates the seeded users defined in Data\EntraUsers.csv
    #>


    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('EntraUser')]
    param(
        [Parameter()]
        [string[]]$UserKey,

        [Parameter()]
        [ValidateSet('Core', 'Bulk')]
        [string[]]$Tier,

        [Parameter()]
        [switch]$SkipManagers,

        [Parameter()]
        [switch]$ShowProgress,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-EntraConnection
    $marker = Get-EntraSeedMarker -Connection $connection

    $definitions = @(Get-EntraSeedData -Name 'EntraUsers')
    if ($Tier) { $definitions = @($definitions | Where-Object { $Tier -contains $_.Tier }) }
    if ($UserKey) {
        $definitions = @($definitions | Where-Object { $UserKey -contains $_.Key })
        $missing = @($UserKey | Where-Object { $definitions.Key -notcontains $_ })
        if ($missing) {
            Write-Error "No seed definition for user key(s): $($missing -join ', ')" -ErrorAction Stop
            return
        }
    }

    Write-Verbose "Creating $($definitions.Count) user(s)"

    # --- Phase 1: create ---------------------------------------------------------------
    $createRequests = [System.Collections.Generic.List[object]]::new()
    $definitionByKey = @{}

    foreach ($definition in $definitions) {
        $upn = '{0}{1}@{2}' -f $marker.Prefix, $definition.Key, $marker.UpnSuffix
        $definitionByKey[$definition.Key] = $definition

        # ShouldProcess is asked per object even though the send is batched, so -WhatIf still
        # names every user and -Confirm can still decline one.
        if (-not $PSCmdlet.ShouldProcess($upn, 'Create user')) { continue }

        $body = @{
            accountEnabled    = [bool]::Parse($definition.AccountEnabled)

            # The one object that does NOT take the seed prefix on its name. Seeded people carry
            # real names on purpose: the same person exists in the AD lab, and matching them by
            # display name is what makes hybrid identity testable. Prefixing here broke that for
            # no gain, because a user's ownership is proved by the administrative unit holding
            # it and by the prefix still present on its UPN - neither of which a human reads.
            displayName       = $definition.DisplayName
            mailNickname      = ('{0}{1}' -f $marker.Prefix, $definition.Key) -replace '[^A-Za-z0-9]', ''
            userPrincipalName = $upn
            passwordProfile   = @{
                forceChangePasswordNextSignIn = $false
                password                      = New-TestPassword
            }
        }

        # Only send optional properties that carry a value. Sending an empty string is not the
        # same as omitting it - Entra stores the empty string, and 'set to nothing' then looks
        # different from 'never set' to anything reading the directory afterwards.
        if ($definition.GivenName) { $body.givenName = $definition.GivenName }
        if ($definition.Surname) { $body.surname = $definition.Surname }
        if ($definition.Department) { $body.department = $definition.Department }
        if ($definition.JobTitle) { $body.jobTitle = $definition.JobTitle }
        if ($definition.EmployeeId) { $body.employeeId = $definition.EmployeeId }
        if ($definition.EmployeeType) { $body.employeeType = $definition.EmployeeType }

        # Owen deliberately has none. Entra accepts the user and then refuses every licence
        # assignment for it, which is the most common licensing failure there is.
        if ($definition.UsageLocation) { $body.usageLocation = $definition.UsageLocation }

        $createRequests.Add([PSCustomObject]@{
                Reference = $definition.Key
                Method    = 'POST'
                Url       = '/users'
                Body      = $body
            })
    }

    if ($createRequests.Count -eq 0) {
        Write-TestProgress -Activity 'Seeding users' -Completed -ShowProgress:$ShowProgress
        return
    }

    $createResults = @(Invoke-EntraBatch -Request $createRequests -Connection $connection `
            -Activity 'Seeding users' -ShowProgress:$ShowProgress)

    $idByKey = @{}
    foreach ($result in $createResults) {
        if ($result.Success -and $result.Body.id) { $idByKey[$result.Reference] = $result.Body.id }
        else { Write-Warning "Could not create user '$($result.Reference)': $($result.Error)" }
    }
    Write-Verbose "Created $($idByKey.Count) of $($createRequests.Count) user(s)"

    # --- Phase 2: seed tag -------------------------------------------------------------
    # Separate because Graph rejects onPremisesExtensionAttributes on POST /users.
    if ($idByKey.Count -gt 0) {
        $tagRequests = foreach ($key in $idByKey.Keys) {
            [PSCustomObject]@{
                Reference = $key
                Method    = 'PATCH'
                Url       = "/users/$($idByKey[$key])"
                Body      = @{
                    onPremisesExtensionAttributes = @{ extensionAttribute15 = $marker.Tag }
                    companyName                   = $marker.Tag
                }
            }
        }

        $tagResults = @(Invoke-EntraBatch -Request @($tagRequests) -Connection $connection `
                -Activity 'Tagging users' -ShowProgress:$ShowProgress)
        $untagged = @($tagResults | Where-Object { -not $_.Success })
        if ($untagged.Count -gt 0) {
            Write-Warning ("$($untagged.Count) user(s) were created but could not be tagged. They remain " +
                "identifiable by their prefixed UPN and their administrative unit, so teardown will still find them.")
        }
    }

    # --- Phase 3: managers -------------------------------------------------------------
    if (-not $SkipManagers -and $idByKey.Count -gt 0) {
        $managerRequests = [System.Collections.Generic.List[object]]::new()

        foreach ($key in $idByKey.Keys) {
            $definition = $definitionByKey[$key]
            if (-not $definition.Manager) { continue }

            if (-not $idByKey.ContainsKey($definition.Manager)) {
                Write-Verbose "User '$key' names manager '$($definition.Manager)', which was not created in this run."
                continue
            }

            $managerRequests.Add([PSCustomObject]@{
                    Reference = $key
                    Method    = 'PUT'
                    Url       = "/users/$($idByKey[$key])/manager/`$ref"
                    Body      = @{ '@odata.id' = "$($connection.GraphBaseUri)/v1.0/users/$($idByKey[$definition.Manager])" }
                })
        }

        if ($managerRequests.Count -gt 0) {
            $managerResults = @(Invoke-EntraBatch -Request $managerRequests.ToArray() -Connection $connection `
                    -Activity 'Setting managers' -ShowProgress:$ShowProgress)
            $failed = @($managerResults | Where-Object { -not $_.Success })
            if ($failed.Count -gt 0) {
                Write-Warning "$($failed.Count) manager relationship(s) could not be set. First error: $($failed[0].Error)"
            }
            Write-Verbose "Set $(@($managerResults | Where-Object Success).Count) manager relationship(s)"
        }
    }

    # --- Phase 4: containment ----------------------------------------------------------
    if ($idByKey.Count -gt 0) {
        $unit = @(Get-EntraSeededObject -Type AdministrativeUnits -Connection $connection) |
            Where-Object { $_.displayName -eq ('{0}Users' -f $marker.Prefix) } | Select-Object -First 1

        if ($unit) {
            $placed = Add-EntraUnitMember -UnitId $unit.id -ObjectId @($idByKey.Values) -Connection $connection `
                -Activity 'Placing users in their administrative unit' -ShowProgress:$ShowProgress
            Write-Verbose "Placed $placed user(s) in '$($unit.displayName)'"
        }
        else {
            Write-Warning ("No $($marker.Prefix)Users administrative unit exists, so the created users are not " +
                "contained. Teardown will fall back to matching on the prefixed UPN. Run " +
                "New-EntraAdministrativeUnit first.")
        }
    }

    Write-TestProgress -Activity 'Seeding users' -Completed -ShowProgress:$ShowProgress

    if ($PassThru) {
        return @(foreach ($key in $idByKey.Keys) {
                $definition = $definitionByKey[$key]
                [PSCustomObject]@{
                    PSTypeName        = 'EntraUser'
                    Key               = $key
                    Id                = $idByKey[$key]
                    DisplayName       = $definition.DisplayName
                    UserPrincipalName = '{0}{1}@{2}' -f $marker.Prefix, $key, $marker.UpnSuffix
                    Department        = $definition.Department
                    AccountEnabled    = [bool]::Parse($definition.AccountEnabled)
                    UsageLocation     = $definition.UsageLocation
                    Tier              = $definition.Tier
                    Purpose           = $definition.Purpose
                }
            })
    }
}