Public/Test-SPMigrationPlan.ps1

function Test-SPMigrationPlan {
    <#
    .SYNOPSIS
        Validation pré-vol d'un plan de migration.
 
    .DESCRIPTION
        Vérifie, AVANT tout transfert, ce qui ferait échouer la migration en cours
        de route : longueur d'URL projetée, caractères interdits, noms réservés,
        fichiers vides ou trop volumineux, extensions bloquées, fichiers extraits.
 
        C'est l'étape que faisaient ShareGate et SPMT et que l'outil précédent
        n'avait pas du tout. Elle transforme des échecs partiels difficiles à
        reprendre en une liste d'anomalies connues avant le lancement.
 
        Sans -Online, la validation est purement locale : structure du plan,
        cohérence des options, règles de nommage applicables aux tâches déclarées.
        Avec -Online, l'arborescence source est réellement énumérée.
 
    .PARAMETER Plan
        Plan produit par New-SPMigrationPlan.
 
    .PARAMETER Online
        Se connecte et énumère la source pour valider chaque élément.
 
    .PARAMETER MaxItems
        Nombre maximal d'éléments inspectés en mode -Online (0 = illimité).
        Toute troncature est signalée explicitement dans le résultat.
 
    .EXAMPLE
        $plan = New-SPMigrationPlan -Path .\plan.json
        $result = Test-SPMigrationPlan -Plan $plan -Online
        $result.Findings | Where-Object Severity -eq 'Error'
 
    .OUTPUTS
        Objet { Ok, Findings, Statistics, Truncated }.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$Plan,

        [switch]$Online,

        # Ajoute la sonde de capacité du tenant. SEUL mode où le pré-vol écrit :
        # un dossier temporaire à destination, supprimé ensuite. Sans elle, le
        # pré-vol ne dit jamais si la copie serveur est autorisée ici — la
        # question qui décide de la préservation des versions.
        [switch]$ProbeCapability,

        [ValidateRange(0, [int]::MaxValue)]
        [int]$MaxItems = 0
    )

    process {
        $findings = [System.Collections.Generic.List[object]]::new()
        $stats = [ordered]@{
            ItemsInspected = 0
            Folders = 0
            Files = 0
            TotalBytes = 0
            MaxUrlLength = 0
            MaxDepth = 0
            LongestPath = ''
            LargestFile = ''
            LargestBytes = 0
            EnumerationFailures = 0
            Labeled = 0
            Records = 0
        }
        $truncated = $false
        $capacite = $null

        # ------------------------------------------------ validations locales
        if ($Plan.Source.Type -eq 'fileshare') {
            if (-not (Test-Path -LiteralPath $Plan.Source.Path)) {
                $findings.Add((New-SPMValidationFinding -Severity Error -Rule 'source.path' `
                            -Message "Chemin source introuvable : $($Plan.Source.Path)" `
                            -Remediation 'Vérifiez le chemin ou les droits d''accès au partage.'))
            }
        }

        if ($Plan.Options.preservePermissions) {
            $findings.Add((New-SPMValidationFinding -Severity Info -Rule 'options.preservePermissions' `
                        -Message 'Les permissions uniques seront reportées après le transfert : héritage rompu comme à la source, attributions remises à plat, surplus de droit rapporté.' `
                        -Remediation 'Fournissez -IdentityMapPath si les principaux diffèrent entre les deux tenants ; sans table, les identités source sont réutilisées telles quelles.'))
        }
        else {
            $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'options.preservePermissions' `
                        -Message 'preservePermissions = false : les éléments à héritage rompu arriveront sur les droits du dossier parent, donc potentiellement visibles de plus de monde qu''à la source.' `
                        -Remediation 'Passez preservePermissions à true, ou assumez explicitement cet écart de droits.'))
        }

        if (-not $Plan.Options.preserveVersions) {
            $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'options.preserveVersions' `
                        -Message 'preserveVersions = false : seule la version courante sera migrée, l''historique sera perdu.' `
                        -Remediation 'Passez preserveVersions à true sauf besoin explicite de réduire le volume.'))
        }

        if ($Plan.Auth.Mode -eq 'interactive') {
            $findings.Add((New-SPMValidationFinding -Severity Info -Rule 'auth.mode' `
                        -Message 'Mode interactif : une invite d''authentification sera présentée. Inadapté à une exécution planifiée.' `
                        -Remediation 'Utilisez auth.mode = certificate ou managedidentity pour une exécution non surveillée.'))
        }

        # -------------------------------------------------- validations en ligne
        if ($Online) {
            # Chemins DÉCODÉS : voir Get-SPMServerRelativePath. Dériver de
            # .AbsolutePath ici ferait mesurer la longueur d'URL sur une forme
            # percent-encodée, donc sur des caractères qui n'existent pas.
            $destRoot = Get-SPMServerRelativePath -Url $Plan.Destination.Url

            # La connexion destination est ouverte des maintenant : elle sera reutilisee
            # par le pool lors de la migration, et son ouverture valide les identifiants.
            $dstConnection = Get-SPMPlanConnection -Plan $Plan -Which Destination

            $items = @()
            if ($Plan.Source.Type -eq 'sharepoint') {
                $srcConnection = Get-SPMPlanConnection -Plan $Plan -Which Source
                $srcFolder = Get-SPMServerRelativePath -Url $Plan.Source.Url

                Write-SPMLog -Level INFO -Operation 'Preflight' -Message "Énumération de la source : $srcFolder"
                Clear-SPMEnumerationFailure
                $items = @(Get-SPMFolderInventory -FolderUrl $srcFolder -Connection $srcConnection)

                # Un sous-arbre illisible fausse tout ce qui suit : les compteurs,
                # la plus longue URL, le verdict. Le signaler comme erreur est le
                # seul moyen d'empêcher un pré-vol vert sur un inventaire tronqué.
                foreach ($echec in (Get-SPMEnumerationFailure)) {
                    $findings.Add((New-SPMValidationFinding -Severity Error -Rule 'source.enumeration' `
                                -Message "Dossier illisible : le sous-arbre n'a pas pu être inspecté ($($echec.Error))" `
                                -Path $echec.FolderUrl `
                                -Remediation 'Vérifiez les permissions du compte de migration sur ce dossier, puis relancez le pré-vol.'))
                }
                $stats.EnumerationFailures = @(Get-SPMEnumerationFailure).Count

                # Zéro élément sur une source SharePoint déclarée n'est presque
                # jamais un vrai « dossier vide » : c'est le symptôme d'une URL
                # qui ne désigne pas ce qu'on croit.
                if ($items.Count -eq 0 -and $stats.EnumerationFailures -eq 0) {
                    $findings.Add((New-SPMValidationFinding -Severity Error -Rule 'source.empty' `
                                -Message 'Aucun élément trouvé à la source. Un pré-vol ne peut rien conclure sur une énumération vide.' `
                                -Path $srcFolder `
                                -Remediation 'Vérifiez que source.url désigne bien une bibliothèque ou un dossier existant, et que le compte y a accès.'))
                }
            }
            else {
                Write-SPMLog -Level INFO -Operation 'Preflight' -Message "Énumération du partage : $($Plan.Source.Path)"
                $base = (Resolve-Path -LiteralPath $Plan.Source.Path).Path
                $items = @(Get-ChildItem -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object {
                        [pscustomobject]@{
                            Name = $_.Name
                            ServerRelativeUrl = $_.FullName.Substring($base.Length).Replace('\', '/').TrimStart('/')
                            IsFolder = $_.PSIsContainer
                            SizeBytes = if ($_.PSIsContainer) { 0 } else { $_.Length }
                            LastModified = $_.LastWriteTime
                            Depth = ($_.FullName.Substring($base.Length).Split([char]'\').Count - 1)
                        }
                    })
            }

            if ($MaxItems -gt 0 -and $items.Count -gt $MaxItems) {
                $truncated = $true
                Write-SPMLog -Level WARNING -Operation 'Preflight' -Message (
                    "Inspection limitée à $MaxItems éléments sur $($items.Count) : la couverture est PARTIELLE."
                )
                $items = $items[0..($MaxItems - 1)]
            }

            $blocked = @($Plan.Validation.blockedExtensions | ForEach-Object { "$_".ToLowerInvariant() })

            foreach ($item in $items) {
                $stats.ItemsInspected++
                if ($item.IsFolder) { $stats.Folders++ } else {
                    $stats.Files++
                    $stats.TotalBytes += $item.SizeBytes
                    if ($item.SizeBytes -gt $stats.LargestBytes) {
                        $stats.LargestBytes = $item.SizeBytes
                        $stats.LargestFile = $item.ServerRelativeUrl
                    }
                }
                if ($item.Depth -gt $stats.MaxDepth) { $stats.MaxDepth = $item.Depth }

                # --- nom ---
                $nameProblems = Test-SPMItemName -Name $item.Name -IsFolder:$item.IsFolder
                foreach ($p in $nameProblems) {
                    $findings.Add((New-SPMValidationFinding -Severity Error -Rule 'name' `
                                -Message $p -Path $item.ServerRelativeUrl `
                                -Remediation 'Renommez l''élément à la source avant migration.'))
                }

                # --- contraintes fichier ---
                if (-not $item.IsFolder) {
                    # L'état d'extraction n'était pas transmis : l'option
                    # validation.failOnCheckedOut existait dans le plan, était
                    # documentée, et n'était lue par aucune ligne. Une option
                    # qui ne fait rien est pire qu'une option absente.
                    $estExtrait = [bool]($item.PSObject.Properties['IsCheckedOut'] -and $item.IsCheckedOut)

                    $fileProblems = Test-SPMFileConstraint -Name $item.Name -SizeBytes $item.SizeBytes `
                        -BlockedExtensions $blocked -AllowEmptyFile:([bool]$Plan.Validation.allowEmptyFiles) `
                        -IsCheckedOut:$estExtrait
                    foreach ($p in $fileProblems) {
                        if ($p -in $nameProblems) { continue }

                        # Un fichier extrait bloque ou avertit selon le plan :
                        # son contenu non archivé ne partira pas, mais c'est à
                        # l'exploitant de décider si cela justifie un arrêt.
                        $severite = if ($p -match 'extrait|checked out') {
                            if ($Plan.Validation.failOnCheckedOut) { 'Error' } else { 'Warning' }
                        }
                        else { 'Error' }

                        $findings.Add((New-SPMValidationFinding -Severity $severite -Rule 'file' `
                                    -Message $p -Path $item.ServerRelativeUrl))
                    }
                }

                # --- longueur d'URL PROJETÉE à destination ---
                $relative = $item.ServerRelativeUrl
                if ($Plan.Source.Type -eq 'sharepoint') {
                    $srcBase = Get-SPMServerRelativePath -Url $Plan.Source.Url
                    if ($relative.StartsWith($srcBase, [StringComparison]::OrdinalIgnoreCase)) {
                        $relative = $relative.Substring($srcBase.Length).TrimStart('/')
                    }
                }
                $projected = "$destRoot/$relative"

                $urlCheck = Test-SPMUrlLength -ServerRelativeUrl $projected -MaxLength $Plan.Validation.maxUrlLength
                if ($urlCheck.Length -gt $stats.MaxUrlLength) {
                    $stats.MaxUrlLength = $urlCheck.Length
                    $stats.LongestPath = $projected
                }
                if (-not $urlCheck.Ok) {
                    $findings.Add((New-SPMValidationFinding -Severity Error -Rule 'url.length' `
                                -Message "URL de destination de $($urlCheck.Length) caractères (limite $($urlCheck.MaxLength))" `
                                -Path $projected -Remediation $urlCheck.RecommendedFix))
                }
                elseif ($urlCheck.Margin -lt 40) {
                    $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'url.length' `
                                -Message "URL de destination proche de la limite (marge : $($urlCheck.Margin) caractères)" `
                                -Path $projected))
                }
            }

            # --------------------------- rétention, enregistrements, modération
            # Ces états ne se copient pas avec le fichier. Les compter ici est la
            # seule occasion de décider en connaissance de cause : une fois la
            # destination peuplée, une étiquette ne se rattrape pas à l'aveugle.
            if ($Plan.Source.Type -eq 'sharepoint') {
                try {
                    $libSource = Get-SPMListTitleForPath -ServerRelativeUrl (Get-SPMServerRelativePath -Url $Plan.Source.Url) `
                        -Connection $srcConnection

                    if ($libSource) {
                        $conformite = Get-SPMComplianceInventory -LibraryTitle $libSource -Connection $srcConnection
                        $stats.Labeled = $conformite.Labeled
                        $stats.Records = $conformite.Records

                        if ($conformite.Labeled -or $conformite.Records) {
                            $severite = if ($Plan.Validation.acceptLabelLoss) { 'Warning' } else { 'Error' }
                            $exemples = @($conformite.Samples | ForEach-Object { $_.Path }) -join ', '

                            $findings.Add((New-SPMValidationFinding -Severity $severite -Rule 'compliance.labels' `
                                        -Message (
                                        "$($conformite.Labeled) élément(s) sous étiquette de rétention et " +
                                        "$($conformite.Records) enregistrement(s). L'étiquette est réappliquée après copie ; " +
                                        "le statut d'enregistrement, lui, N'EST PAS restaurable et devra être redéclaré." +
                                        $(if ($exemples) { " Exemples : $exemples" })
                                    ) `
                                        -Remediation 'Posez validation.acceptLabelLoss = true pour accepter ce risque en connaissance de cause, après avoir relevé les éléments concernés.'))
                        }

                        if ($conformite.Pending) {
                            $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'compliance.moderation' `
                                        -Message "$($conformite.Pending) élément(s) en attente d'approbation ou rejeté(s) : sans modération active à destination, ils y seraient visibles de tous." `
                                        -Remediation 'Répliquez les réglages de bibliothèque (Copy-SPSiteSchema) avant de transférer.'))
                        }
                    }
                }
                catch {
                    $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'compliance.scan' `
                                -Message "Rétention et modération non inventoriées : $(($_.Exception.Message -split [char]10)[0])" `
                                -Remediation 'Sans cet inventaire, une perte d''étiquette ne serait découverte qu''en audit.'))
                }
            }

            # ------------------------------------- capacité mesurée du tenant
            # Le pré-vol savait dire ce que la SOURCE contient, jamais ce que la
            # DESTINATION autorise. Un plan parfaitement valide échouait donc au
            # premier transfert, sur un refus qu'un appel de trente secondes
            # aurait révélé avant le lancement.
            if ($Plan.Source.Type -eq 'sharepoint') {
                try {
                    $capacite = Test-SPMTenantCapability -SourceUrl $Plan.Source.Url -TargetUrl $Plan.Destination.Url `
                        -SourceConnection $srcConnection -DestinationConnection $dstConnection `
                        -ProbeCopy:$ProbeCapability -Confirm:$false

                    if ($capacite.CanCreateList -eq $false) {
                        $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'tenant.manageLists' `
                                    -Message 'Le compte n''a pas le droit de créer une bibliothèque à destination : la recréation de schéma échouera.' `
                                    -Remediation 'Créez les bibliothèques cibles à la main, ou accordez ManageLists au compte de migration.'))
                    }

                    if ($capacite.ServerCopy -eq 'non') {
                        # Le seul cas où le pré-vol peut affirmer que la migration
                        # ne transférerait rien : copie refusée, aucun repli.
                        if ($Plan.Options.allowLocalFallback) {
                            $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'tenant.serverCopy' `
                                        -Message 'Copie serveur REFUSÉE par le tenant : le repli local prendra le relais et l''HISTORIQUE DES VERSIONS sera perdu.' `
                                        -Remediation 'Faites lever la restriction, ou acceptez explicitement la perte des versions.'))
                        }
                        else {
                            $findings.Add((New-SPMValidationFinding -Severity Error -Rule 'tenant.serverCopy' `
                                        -Message 'Copie serveur REFUSÉE par le tenant et repli local désactivé : la migration ne transférerait rien.' `
                                        -Remediation 'Faites lever la restriction, ou posez options.allowLocalFallback = true en acceptant la perte des versions.'))
                        }
                    }
                    elseif ($capacite.ServerCopy -eq 'oui') {
                        $findings.Add((New-SPMValidationFinding -Severity Info -Rule 'tenant.serverCopy' `
                                    -Message 'Copie serveur AUTORISÉE, mesurée : l''historique des versions est récupérable.'))
                    }
                    else {
                        $findings.Add((New-SPMValidationFinding -Severity Info -Rule 'tenant.serverCopy' `
                                    -Message 'Copie serveur non éprouvée : le pré-vol n''écrit pas sans -ProbeCapability.' `
                                    -Remediation 'Relancez avec -ProbeCapability pour trancher avant la migration, pas pendant.'))
                    }
                }
                catch {
                    $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'tenant.capability' `
                                -Message "Capacité du tenant non mesurée : $(($_.Exception.Message -split [char]10)[0])" `
                                -Remediation 'La migration reste possible, mais un refus du service ne sera découvert qu''en cours de route.'))
                }
            }

            if ($stats.Files -gt $script:SPMLimits.ListViewThreshold) {
                $findings.Add((New-SPMValidationFinding -Severity Warning -Rule 'listview.threshold' `
                            -Message "$($stats.Files) fichiers dans un même périmètre : au-delà du seuil d'affichage de $($script:SPMLimits.ListViewThreshold)." `
                            -Remediation 'Prévoyez des colonnes indexées et des vues filtrées à destination.'))
            }
        }

        $errors = @($findings | Where-Object Severity -eq 'Error')
        $warnings = @($findings | Where-Object Severity -eq 'Warning')

        $result = [pscustomobject]@{
            Ok = ($errors.Count -eq 0)
            Findings = $findings.ToArray()
            ErrorCount = $errors.Count
            WarnCount = $warnings.Count
            Statistics = [pscustomobject]$stats
            Truncated = $truncated
            Online = [bool]$Online
            Capability = $capacite
        }

        Write-SPMLog -Level $(if ($result.Ok) { 'SUCCESS' } else { 'ERROR' }) -Operation 'Preflight' -Message (
            "Pré-vol : $($errors.Count) erreur(s), $($warnings.Count) avertissement(s), " +
            "$($stats.ItemsInspected) élément(s) inspecté(s)$(if ($truncated) { ' [TRONQUÉ]' })"
        ) -Data @{
            errors = $errors.Count
            warnings = $warnings.Count
            inspected = $stats.ItemsInspected
            truncated = $truncated
        }

        return $result
    }
}