tests/Test-Microsoft365AccessProfiles.ps1

$ErrorActionPreference = 'Stop'
$script:AssertionCount = 0

function Assert-True {
    param([bool]$Condition, [string]$Message)
    $script:AssertionCount++
    if (-not $Condition) { throw "Assertion failed: $Message" }
}

function Assert-Equal {
    param($Expected, $Actual, [string]$Message)
    $script:AssertionCount++
    if ($Expected -ne $Actual) {
        throw "Assertion failed: $Message. Expected '$Expected', got '$Actual'."
    }
}

$moduleRoot = Join-Path (Split-Path -Parent $PSScriptRoot) 'modules'
Import-Module (Join-Path $moduleRoot 'ISC.OperatorConsole.psm1') -Force -WarningAction SilentlyContinue
Import-Module (Join-Path $moduleRoot 'ISC.SailPointSdk.psm1') -Force -WarningAction SilentlyContinue
Import-Module (Join-Path $moduleRoot 'ISC.Microsoft365AccessProfiles.psm1') -Force -WarningAction SilentlyContinue

# --- YAML env catalog ---
$yaml = @'
activeenvironment: emea-tes-team
authtype: pat
environments:
  emea-tes-team:
    authtype: pat
    baseurl: https://company24509-poc.api.identitynow-demo.com
    tenanturl: https://company24509-poc.identitynow-demo.com
  fernando:
    baseurl: https://company12926-poc.api.identitynow-demo.com
    tenanturl: https://company12926-poc.identitynow-demo.com
'@

$parsed = ConvertFrom-SailpointSimpleYaml -Content $yaml
Assert-Equal 'emea-tes-team' $parsed.ActiveEnvironment 'active environment parsed'
Assert-Equal 2 $parsed.Environments.Count 'two environments parsed'
$emea = @($parsed.Environments | Where-Object { $_.Name -eq 'emea-tes-team' } | Select-Object -First 1)
Assert-True ($emea.BaseUrl -like 'https://company24509-poc.api.*') 'emea baseurl parsed'

# --- Credential resolution order ---
$priorBase = $env:SAIL_BASE_URL
$priorId = $env:SAIL_CLIENT_ID
$priorSecret = $env:SAIL_CLIENT_SECRET
try {
    Remove-Item Env:SAIL_BASE_URL -ErrorAction SilentlyContinue
    Remove-Item Env:SAIL_CLIENT_ID -ErrorAction SilentlyContinue
    Remove-Item Env:SAIL_CLIENT_SECRET -ErrorAction SilentlyContinue
    Assert-True ($null -eq (Get-SailpointSdkConfigFromEnvironment)) 'no env credentials when unset'

    Set-SailpointProcessCredentials -BaseUrl 'https://example.api.identitynow.com' -ClientId 'id' -ClientSecret 'secret'
    $fromEnv = Get-SailpointSdkConfigFromEnvironment
    Assert-equal 'environment' $fromEnv.Source 'env credentials source'
    Assert-equal 'https://example.api.identitynow.com' $fromEnv.BaseUrl 'env base url'

    $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("sail-config-{0}.json" -f [guid]::NewGuid())
    @{ BaseURL = 'https://file.api.identitynow.com'; ClientId = 'file-id'; ClientSecret = 'file-secret' } |
        ConvertTo-Json | Set-Content -LiteralPath $tmp -Encoding UTF8
    $fromFile = Get-SailpointSdkConfigFromFile -Path $tmp
    Assert-equal 'config.json' $fromFile.Source 'file credentials source'
    Assert-equal 'file-id' $fromFile.ClientId 'file client id'
    Remove-Item -LiteralPath $tmp -Force
}
finally {
    if ($null -ne $priorBase) { $env:SAIL_BASE_URL = $priorBase } else { Remove-Item Env:SAIL_BASE_URL -ErrorAction SilentlyContinue }
    if ($null -ne $priorId) { $env:SAIL_CLIENT_ID = $priorId } else { Remove-Item Env:SAIL_CLIENT_ID -ErrorAction SilentlyContinue }
    if ($null -ne $priorSecret) { $env:SAIL_CLIENT_SECRET = $priorSecret } else { Remove-Item Env:SAIL_CLIENT_SECRET -ErrorAction SilentlyContinue }
}

# --- Entra source heuristic ---
Assert-True (Test-IsEntraIscSource -Source ([pscustomobject]@{ name = 'Entra Prod'; type = 'Microsoft Entra ID'; connector = 'microsoft-entra-id' })) 'entra source detected'
Assert-True (-not (Test-IsEntraIscSource -Source ([pscustomobject]@{ name = 'AD'; type = 'Active Directory - Direct'; connector = 'active-directory' }))) 'AD source rejected'

# --- Friendly name mapping ---
$csv = @'
Product_Display_Name,String_Id,GUID,Service_Plan_Name,Service_Plan_Id,Service_Plans_Included_Friendly_Names
Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,FLOW_O365_P2,c1ec4a95-1f57-4f4a-8c5b-1d5f0b0c0f11,Flow for Office 365 (c1ec4a95-1f57-4f4a-8c5b-1d5f0b0c0f11)
Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams (57ff2da0-773e-42df-b2af-ffb7a2317929)
Microsoft Entra ID P1,AAD_PREMIUM,078d2b04-f1bd-4111-bbd4-b4b1b354cef4,AAD_PREMIUM,41781fb2-bc02-4b7c-bd55-b576c07bb09d,Microsoft Entra ID P1 (41781fb2-bc02-4b7c-bd55-b576c07bb09d)
Microsoft 365 Business Basic,O365_BUSINESS_ESSENTIALS,3b555118-da6a-4418-894f-7df1e2096870,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,YAMMER_ENTERPRISE
Microsoft 365 Business Premium,SPB,cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,Yammer Enterprise
'@

$map = ConvertFrom-MicrosoftLicensingCsv -CsvText $csv
Assert-equal 'Yammer Enterprise' $map.ByPlanName['YAMMER_ENTERPRISE'] 'prefers human friendly over raw code'
Assert-equal 'Microsoft Nucleus' $map.ByPlanName['NUCLEUS'] 'override for Nucleus'
Assert-equal 'Exchange Online (Plan 2)' $map.ByPlanName['EXCHANGE_S_ENTERPRISE'] 'Exchange Online Plan 2 override'
$flowEnt = [pscustomobject]@{ id = 'ent-flow'; name = 'FLOW_O365_P2'; value = 'c1ec4a95-1f57-4f4a-8c5b-1d5f0b0c0f11'; type = 'servicePlan' }
$teamsEnt = [pscustomobject]@{ id = 'ent-teams'; name = 'TEAMS1'; value = '57ff2da0-773e-42df-b2af-ffb7a2317929'; type = 'servicePlan' }
Assert-equal 'Flow for Office 365' (Resolve-ServicePlanFriendlyName -Entitlement $flowEnt -FriendlyNameMap $map) 'flow friendly name'
Assert-equal 'Microsoft Teams' (Resolve-ServicePlanFriendlyName -Entitlement $teamsEnt -FriendlyNameMap $map) 'teams friendly name'

# Entra connector names: "PLAN [of] SKU" (developer pack, enterprise, etc.)
$devEnt = [ordered]@{
    id    = 'ent-aad-dev'
    name  = 'AAD_PREMIUM [of] DEVELOPERPACK_E5'
    value = 'AAD_PREMIUM [of] DEVELOPERPACK_E5'
    type  = 'servicePlan'
}
Assert-equal 'Microsoft Entra ID P1' (Resolve-ServicePlanFriendlyName -Entitlement $devEnt -FriendlyNameMap $map) 'strips [of] SKU for friendly name'
$devRows = @(Build-ServicePlanSelectionRows -Entitlements @($devEnt) -FriendlyNameMap $map)
Assert-equal 'AAD_PREMIUM' $devRows[0].InternalName 'internal name is bare plan id'
Assert-True ($devRows[0].Label -match 'Microsoft Entra ID P1') 'label uses Microsoft friendly name'

# StrictMode-safe when SDK entitlement omits value
$noValueEnt = [pscustomobject]@{ id = 'ent-noval'; name = 'POWERAPPS_O365_P2'; type = 'servicePlan' }
Assert-equal 'POWERAPPS_O365_P2' (Resolve-ServicePlanFriendlyName -Entitlement $noValueEnt -FriendlyNameMap $map) 'name fallback without value'
$rowsNoValue = @(Build-ServicePlanSelectionRows -Entitlements @($noValueEnt) -FriendlyNameMap $map)
Assert-equal 1 @($rowsNoValue).Count 'row built without value property'
Assert-equal 'POWERAPPS_O365_P2' $rowsNoValue[0].InternalName 'internal name from name only'

# Nested entitlement array must flatten into multiple selection rows
$nested = ,@($flowEnt, $teamsEnt)
$rowsNested = @(Build-ServicePlanSelectionRows -Entitlements $nested -FriendlyNameMap $map)
Assert-equal 2 @($rowsNested).Count 'nested entitlements flatten to rows'
Assert-True (@($rowsNested | Where-Object { $_.Id }).Count -eq 2) 'flattened rows have option keys'

# SDK array payloads arrive as hashtables (ConvertFrom-Json -AsHashtable)
$flowHash = [ordered]@{
    id        = 'ent-flow-hash'
    name      = 'FLOW_O365_P2'
    value     = 'c1ec4a95-1f57-4f4a-8c5b-1d5f0b0c0f11'
    attribute = 'servicePlan'
    type      = 'servicePlan'
}
Assert-equal 'Flow for Office 365' (Resolve-ServicePlanFriendlyName -Entitlement $flowHash -FriendlyNameMap $map) 'hashtable entitlement friendly name'
$rowsHash = @(Build-ServicePlanSelectionRows -Entitlements @($flowHash) -FriendlyNameMap $map)
Assert-equal 'FLOW_O365_P2' $rowsHash[0].InternalName 'hashtable entitlement internal name'
Assert-True ($rowsHash[0].Label -notmatch 'Unknown') 'hashtable entitlement label is not Unknown'

$flatFetch = @(Get-ServicePlanEntitlementsForSource -SourceId 'src' -EntitlementFetcher {
        param($filters)
        # Simulate accidental nested return
        , @($flowEnt, $teamsEnt)
    })
Assert-equal 2 @($flatFetch).Count 'entitlement fetch flattens nested arrays'

$rows = @(Build-ServicePlanSelectionRows -Entitlements @($flowEnt, $teamsEnt) -FriendlyNameMap $map)
Assert-Equal 2 @($rows).Count 'selection rows built'
Assert-True (@($rows)[0].Label -match '\(') 'selection label includes internal name'

Assert-equal 'M365 - Microsoft Teams' (New-AccessProfileName -Prefix 'M365 - ' -FriendlyName 'Microsoft Teams') 'prefix applied'
Assert-equal 'M365 - Microsoft Teams' (New-AccessProfileName -Prefix 'M365 -' -FriendlyName 'Microsoft Teams') 'prefix hyphen gets trailing space'
$oldManifestRow = [pscustomobject]@{
    FriendlyName = 'Exchange Online (Plan 2)'
    AccessProfileName = 'Microsoft 365 @emea-tes-team.cloud -Exchange Online (Plan 2)'
}
Assert-equal 'Microsoft 365 @emea-tes-team.cloud - Exchange Online (Plan 2)' `
    (Get-M365ReplayAccessProfileName -ManifestRow $oldManifestRow -CurrentFriendlyName 'Exchange Online (Plan 2)') `
    'CSV replay infers and normalizes the previous prefix'
Assert-equal 'M365 - Microsoft Teams' `
    (Get-M365ReplayAccessProfileName -ManifestRow $oldManifestRow -CurrentFriendlyName 'Microsoft Teams' -ManifestPrefix 'M365 -') `
    'JSON replay rebuilds the name from manifest prefix and current friendly name'
$lookupNames = @(Get-M365AccessProfileLookupNames -ManifestRow $oldManifestRow `
        -CurrentFriendlyName 'Exchange Online (Plan 2)' `
        -ManifestPrefix 'Microsoft 365 @emea-tes-team.cloud -')
Assert-True ($lookupNames -contains 'Microsoft 365 @emea-tes-team.cloud -Exchange Online (Plan 2)') `
    'lookup includes the raw previous name'
Assert-True ($lookupNames -contains 'Microsoft 365 @emea-tes-team.cloud - Exchange Online (Plan 2)') `
    'lookup includes the spaced previous prefix name'

# Previous JSON and CSV manifests load as replay inputs
$tmpManifestDir = Join-Path ([System.IO.Path]::GetTempPath()) ("m365-replay-{0}" -f [guid]::NewGuid())
New-Item -ItemType Directory -Path $tmpManifestDir -Force | Out-Null
try {
    $jsonManifestPath = Join-Path $tmpManifestDir 'previous.json'
    [pscustomobject]@{
        Environment = 'test-env'
        SourceId    = 'src-1'
        SourceName  = 'Entra'
        Prefix      = 'M365 - '
        Application = [pscustomobject]@{ Name = 'Microsoft 365' }
        Results     = @([pscustomobject]@{
                InternalName = 'TEAMS1'; EntitlementId = 'ent-teams'
                AccessProfileName = 'M365 - Microsoft Teams'
            })
    } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $jsonManifestPath -Encoding UTF8
    $jsonReplay = Read-M365AccessProfileManifest -Path $jsonManifestPath
    Assert-equal 'Json' $jsonReplay.Format 'JSON replay format'
    Assert-equal 'src-1' $jsonReplay.SourceId 'JSON replay source'
    Assert-equal 'Microsoft 365' $jsonReplay.ApplicationName 'JSON replay application'
    Assert-equal 1 @($jsonReplay.Results).Count 'JSON replay rows'

    $csvManifestPath = Join-Path $tmpManifestDir 'previous.csv'
    @"
InternalName,EntitlementId,AccessProfileName
TEAMS1,ent-teams,M365 - Microsoft Teams
"@
 | Set-Content -LiteralPath $csvManifestPath -Encoding UTF8
    $csvReplay = Read-M365AccessProfileManifest -Path $csvManifestPath
    Assert-equal 'Csv' $csvReplay.Format 'CSV replay format'
    Assert-equal 1 @($csvReplay.Results).Count 'CSV replay rows'
    Assert-equal 'M365 - Microsoft Teams' $csvReplay.Results[0].AccessProfileName 'CSV replay preserves exact name'
}
finally {
    Remove-Item -LiteralPath $tmpManifestDir -Recurse -Force -ErrorAction SilentlyContinue
}

# --- Idempotent AP resolve ---
$existingAp = [pscustomobject]@{
    id           = 'ap-1'
    name         = 'M365 - Microsoft Teams'
    source       = [pscustomobject]@{ id = 'src-1' }
    entitlements = @([pscustomobject]@{ id = 'ent-teams' })
}
$reused = Resolve-OrCreateAccessProfileForPlan -Name 'M365 - Microsoft Teams' -SourceId 'src-1' -SourceName 'Entra' `
    -OwnerId 'owner-1' -EntitlementId 'ent-teams' `
    -AccessProfileFetcher { param($f) @($existingAp) } `
    -EntitlementListFetcher { param($id) @([pscustomobject]@{ id = 'ent-teams' }) }
Assert-Equal 'skipped' $reused.Status 'existing AP skipped by default'

# Hashtable list responses keep their source scoping (ConvertFrom-Json -AsHashtable)
$existingApHash = [ordered]@{
    id     = 'ap-hash-1'
    name   = 'M365 - Microsoft Teams'
    source = [ordered]@{ id = 'src-1' }
}
$scopedHash = @(Find-AccessProfilesByName -Name 'M365 - Microsoft Teams' -SourceId 'src-1' `
        -AccessProfileFetcher { param($f) @($existingApHash) })
Assert-equal 1 @($scopedHash).Count 'hashtable AP matches its source'
$scopedOther = @(Find-AccessProfilesByName -Name 'M365 - Microsoft Teams' -SourceId 'src-other' `
        -AccessProfileFetcher { param($f) @($existingApHash) })
Assert-equal 0 @($scopedOther).Count 'hashtable AP excluded from another source'

$script:updatedAccessProfile = $null
$updatedExisting = Resolve-OrCreateAccessProfileForPlan -Name 'M365 - Microsoft Teams' -SourceId 'src-1' -SourceName 'Entra' `
    -OwnerId 'owner-1' -EntitlementId 'ent-other' `
    -AccessProfileFetcher { param($f) @($existingAp) } `
    -ExistingItemAction Update `
    -PatchFetcher {
        param($id, $ops)
        $script:updatedAccessProfile = [pscustomobject]@{ Id = $id; Operations = $ops }
        $ops
    }
Assert-Equal 'updated' $updatedExisting.Status 'existing AP fully reconciled'
Assert-Equal 'ap-1' $script:updatedAccessProfile.Id 'existing AP patched by id'
$entitlementOp = @($script:updatedAccessProfile.Operations | Where-Object { $_.path -eq '/entitlements' })[0]
Assert-Equal 'ent-other' $entitlementOp.value[0].id 'existing AP entitlement replaced'
$sourceOp = @($script:updatedAccessProfile.Operations | Where-Object { $_.path -eq '/source' })[0]
Assert-Equal 'src-1' $sourceOp.value.id 'existing AP source reconciled'

$script:renamedFromPrevious = $null
$oldNamedAp = [pscustomobject]@{
    id           = 'ap-old'
    name         = 'Microsoft 365 @t.cloud - Microsoft Teams'
    source       = [pscustomobject]@{ id = 'src-1' }
    entitlements = @([pscustomobject]@{ id = 'ent-teams' })
}
$renamed = Resolve-OrCreateAccessProfileForPlan -Name 'Microsoft 365 @t.cloud (Users) - Microsoft Teams' `
    -SourceId 'src-1' -SourceName 'Entra' -OwnerId 'owner-1' -EntitlementId 'ent-teams' `
    -ExistingItemAction Update `
    -PreviousNames @('Microsoft 365 @t.cloud - Microsoft Teams', 'Microsoft 365 @t.cloud -Microsoft Teams') `
    -AccessProfileFetcher {
        param($f)
        if ($f -match 'Users') { @() } else { @($oldNamedAp) }
    } `
    -PatchFetcher {
        param($id, $ops)
        $script:renamedFromPrevious = [pscustomobject]@{ Id = $id; Operations = $ops }
        $ops
    }
Assert-Equal 'updated' $renamed.Status 'previous-name lookup updates instead of creating'
Assert-Equal 'ap-old' $script:renamedFromPrevious.Id 'rename patches the previous profile'
Assert-Equal 'Microsoft 365 @t.cloud (Users) - Microsoft Teams' `
    (@($script:renamedFromPrevious.Operations | Where-Object { $_.path -eq '/name' })[0].value) `
    'rename sets the new prefix'

$createdPayload = $null
$created = Resolve-OrCreateAccessProfileForPlan -Name 'M365 - Flow for Office 365' -SourceId 'src-1' -SourceName 'Entra' `
    -OwnerId 'owner-1' -EntitlementId 'ent-flow' `
    -AccessProfileFetcher { param($f) @() } `
    -CreateFetcher {
        param($payload)
        $script:createdPayload = $payload
        return [pscustomobject]@{ id = 'ap-new'; name = [string]$payload.name }
    }
Assert-equal 'created' $created.Status 'missing AP created'
Assert-equal 'ap-new' $created.AccessProfileId 'created id returned'
Assert-True ($null -ne $createdPayload) 'create payload captured'
Assert-equal $true ([bool]$createdPayload.requestable) 'created AP is requestable'
Assert-equal 'ent-flow' ([string]$createdPayload.entitlements[0].id) 'created AP binds entitlement'

$whatIf = Resolve-OrCreateAccessProfileForPlan -Name 'M365 - New Plan' -SourceId 'src-1' -SourceName 'Entra' `
    -OwnerId 'owner-1' -EntitlementId 'ent-x' -WhatIf `
    -AccessProfileFetcher { param($f) @() }
Assert-equal 'would-create' $whatIf.Status 'WhatIf does not create'

# Create response deserialized as a hashtable (ConvertFrom-Json -AsHashtable) still yields an id
$createdHash = Resolve-OrCreateAccessProfileForPlan -Name 'M365 - Hash Plan' -SourceId 'src-1' -SourceName 'Entra' `
    -OwnerId 'owner-1' -EntitlementId 'ent-hash' `
    -AccessProfileFetcher { param($f) @() } `
    -CreateFetcher { param($p) [ordered]@{ id = 'ap-hash'; name = [string]$p.name } }
Assert-equal 'created' $createdHash.Status 'hashtable create response is created'
Assert-equal 'ap-hash' $createdHash.AccessProfileId 'hashtable create response yields id'

# Create response without an id falls back to a lookup by name so app linking still gets ids
$script:lookupCalls = 0
$createdLookup = Resolve-OrCreateAccessProfileForPlan -Name 'M365 - Silent Plan' -SourceId 'src-1' -SourceName 'Entra' `
    -OwnerId 'owner-1' -EntitlementId 'ent-silent' `
    -AccessProfileFetcher {
        param($f)
        $script:lookupCalls++
        if ($script:lookupCalls -eq 1) {
            @()
        }
        else {
            @([pscustomobject]@{ id = 'ap-found'; name = 'M365 - Silent Plan'; source = [pscustomobject]@{ id = 'src-1' } })
        }
    } `
    -CreateFetcher { param($p) $null }
Assert-equal 'created' $createdLookup.Status 'idless create response is created'
Assert-equal 'ap-found' $createdLookup.AccessProfileId 'idless create response resolves id by name'

# --- Source app patch ops ---
$ops = @(Build-SourceAppAccessProfilePatch -AccessProfileIds @('ap-1', 'ap-2', 'ap-1') -ExistingAccessProfileIds @('ap-1'))
Assert-equal 1 $ops.Count 'only missing AP is patched'
Assert-equal 'add' $ops[0].op 'patch op is add'
Assert-equal '/accessProfiles/-' $ops[0].path 'patch path appends'
Assert-equal 'ap-2' $ops[0].value 'patch value is new AP id'

$script:sourceAppOwnerPatch = $null
$appCreated = Resolve-OrCreateSourceApp -Name 'Microsoft 365' -SourceId 'src-1' -OwnerId 'id-current' `
    -SourceAppFetcher { param($f) @() } `
    -CreateFetcher { param($p) [pscustomobject]@{ id = 'app-1'; name = $p.name } } `
    -OwnerPatchFetcher {
        param($id, $ops)
        $script:sourceAppOwnerPatch = [pscustomobject]@{ AppId = $id; Operations = $ops }
        $ops
    }
Assert-equal 'created' $appCreated.Status 'source app created'
Assert-equal 'app-1' $appCreated.AppId 'source app id'
Assert-equal 'app-1' $script:sourceAppOwnerPatch.AppId 'source app owner patched on create'
Assert-equal 'id-current' $script:sourceAppOwnerPatch.Operations[0].value.id 'source app owner is current identity'

# App create response without an id falls back to a lookup by name, so linking and owner patch still run
$script:appLookupCalls = 0
$script:idlessOwnerPatch = $null
$appIdless = Resolve-OrCreateSourceApp -Name 'Microsoft 365' -SourceId 'src-1' -OwnerId 'id-current' `
    -SourceAppFetcher {
        param($f)
        $script:appLookupCalls++
        if ($script:appLookupCalls -eq 1) {
            @()
        }
        else {
            @([pscustomobject]@{ id = 'app-found'; name = 'Microsoft 365'; accountSource = [pscustomobject]@{ id = 'src-1' } })
        }
    } `
    -CreateFetcher { param($p) $null } `
    -OwnerPatchFetcher {
        param($id, $ops)
        $script:idlessOwnerPatch = $id
        $ops
    }
Assert-equal 'created' $appIdless.Status 'idless app create response is created'
Assert-equal 'app-found' $appIdless.AppId 'idless app create response resolves id by name'
Assert-equal 'app-found' $script:idlessOwnerPatch 'owner patch uses the resolved app id'

# Hashtable app list responses keep their account-source scoping
$appHash = [ordered]@{ id = 'app-hash'; name = 'Microsoft 365'; accountSource = [ordered]@{ id = 'src-1' } }
$appScoped = @(Find-SourceAppByName -Name 'Microsoft 365' -SourceId 'src-1' -SourceAppFetcher { param($f) @($appHash) })
Assert-equal 1 @($appScoped).Count 'hashtable app matches its account source'
$appScopedOther = @(Find-SourceAppByName -Name 'Microsoft 365' -SourceId 'src-other' -SourceAppFetcher { param($f) @($appHash) })
Assert-equal 0 @($appScopedOther).Count 'hashtable app excluded from another account source'

$appReused = Resolve-OrCreateSourceApp -Name 'Microsoft 365' -SourceId 'src-1' `
    -SourceAppFetcher {
        param($f)
        @([pscustomobject]@{ id = 'app-1'; name = 'Microsoft 365'; accountSource = [pscustomobject]@{ id = 'src-1' } })
    }
Assert-equal 'skipped' $appReused.Status 'existing source app skipped by default'

$script:updatedSourceApp = $null
$appUpdated = Resolve-OrCreateSourceApp -Name 'Microsoft 365' -SourceId 'src-1' -OwnerId 'id-current' `
    -ExistingItemAction Update `
    -SourceAppFetcher {
        param($f)
        @([pscustomobject]@{ id = 'app-1'; name = 'Microsoft 365'; accountSource = [pscustomobject]@{ id = 'src-1' } })
    } `
    -OwnerPatchFetcher {
        param($id, $ops)
        $script:updatedSourceApp = [pscustomobject]@{ Id = $id; Operations = $ops }
        $ops
    }
Assert-equal 'updated' $appUpdated.Status 'existing source app reconciled'
Assert-equal 'id-current' (@($script:updatedSourceApp.Operations | Where-Object { $_.path -eq '/owner' })[0].value.id) `
    'existing source app owner updated'

$link = Add-AccessProfilesToSourceApp -AppId 'app-1' -AccessProfileIds @('ap-1', 'ap-2') `
    -ExistingFetcher { param($id) @([pscustomobject]@{ id = 'ap-1' }) } `
    -PatchFetcher { param($id, $ops) $ops }
Assert-equal 'patched' $link.Status 'app linked'
Assert-equal 1 @($link.Added).Count 'one AP added to app'

# --- Source discovery with mocks ---
$sources = @(
    [pscustomobject]@{ id = 'src-entra'; name = 'Entra Users'; type = 'Microsoft Entra ID'; connector = 'microsoft-entra-id' }
    [pscustomobject]@{ id = 'src-ad'; Name = 'AD'; type = 'Active Directory - Direct'; connector = 'active-directory' }
)
$found = @(Find-EntraSourcesWithServicePlans -SourceFetcher { $sources } -EntitlementFetcher {
        param($filters)
        if ($filters -match 'src-entra') {
            @([pscustomobject]@{ id = 'ent-1'; name = 'TEAMS1'; value = '57ff2da0-773e-42df-b2af-ffb7a2317929'; type = 'servicePlan' })
        }
        else { @() }
    })
Assert-equal 1 $found.Count 'only entra+plans source returned'
Assert-equal 'src-entra' $found[0].Id 'correct source id'
Assert-equal 1 $found[0].PlanCount 'plan count set'

# --- Identity search uses fetcher when provided ---
$identityHits = @(Find-IdentitiesByQuery -Query 'robot' -IdentityFetcher {
        param($q)
        @([pscustomobject]@{ id = 'id-robot'; name = 'Robot Account'; alias = 'robot'; email = 'robot@example.com' })
    })
Assert-equal 1 $identityHits.Count 'identity fetcher returns hit'
Assert-equal 'id-robot' $identityHits[0].id 'identity id preserved'

# --- Current identity from parameter, JWT, or PAT owner=me ---
$fromParam = Get-CurrentIscIdentity -OwnerId 'id-override'
Assert-equal 'id-override' $fromParam.Id 'OwnerId override wins'
Assert-equal 'parameter' $fromParam.Source 'OwnerId source is parameter'

$jwtPayload = '{"identity_id":"id-current","user_name":"alice"}'
$jwtBody = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($jwtPayload)).TrimEnd('=')
$jwt = "e30.$jwtBody.sig"
$fromJwt = Get-CurrentIscIdentity -TokenFetcher { $jwt } -PersonalAccessTokenFetcher { @() }
Assert-equal 'id-current' $fromJwt.Id 'JWT identity_id is current owner'
Assert-equal 'alice' $fromJwt.Name 'JWT user_name is current owner name'
Assert-equal 'token' $fromJwt.Source 'JWT source is token'

$fromPat = Get-CurrentIscIdentity -TokenFetcher { 'not-a-jwt' } -PersonalAccessTokenFetcher {
    @([pscustomobject]@{ owner = [pscustomobject]@{ id = 'id-pat'; name = 'Pat Owner' } })
}
Assert-equal 'id-pat' $fromPat.Id 'PAT owner-id=me is current owner'
Assert-equal 'Pat Owner' $fromPat.Name 'PAT owner name'
Assert-equal 'personal-access-token' $fromPat.Source 'PAT source'

$unresolved = $false
try {
    $null = Get-CurrentIscIdentity -TokenFetcher { $null } -PersonalAccessTokenFetcher { @() }
}
catch { $unresolved = $true }
Assert-True $unresolved 'throws when current identity cannot be resolved'

# --- Manifest format: JSON or CSV only ---
$tmpOut = Join-Path ([System.IO.Path]::GetTempPath()) ("m365-manifest-{0}" -f [guid]::NewGuid())
try {
    $sample = [pscustomobject]@{
        OwnerId = 'id-current'
        Results = @([pscustomobject]@{ AccessProfileName = 'M365 - Teams'; AccessProfileId = 'ap-1' })
    }
    $jsonOnly = Write-M365AccessProfileManifest -Manifest $sample -OutputDirectory $tmpOut -Format Json
    Assert-True ([bool]$jsonOnly.JsonPath) 'JSON format writes json'
    Assert-True (Test-Path -LiteralPath $jsonOnly.JsonPath) 'JSON file exists'
    Assert-True (-not $jsonOnly.CsvPath) 'JSON format skips csv'
    $csvOnly = Write-M365AccessProfileManifest -Manifest $sample -OutputDirectory $tmpOut -Format Csv
    Assert-True ([bool]$csvOnly.CsvPath) 'CSV format writes csv'
    Assert-True (Test-Path -LiteralPath $csvOnly.CsvPath) 'CSV file exists'
    Assert-True (-not $csvOnly.JsonPath) 'CSV format skips json'
}
finally {
    if (Test-Path -LiteralPath $tmpOut) {
        Remove-Item -LiteralPath $tmpOut -Recurse -Force -ErrorAction SilentlyContinue
    }
}

Write-Host "PASS ($script:AssertionCount assertions)"