Public/Test-SPMigrationFidelity.ps1

function Test-SPMigrationFidelity {
    <#
    .SYNOPSIS
        Compare la destination à la source, élément par élément, après migration.
 
    .DESCRIPTION
        Le rapport de migration dérive du fichier d'état : il dit ce que le
        moteur CROIT avoir fait. Cette fonction dit ce qui se trouve
        réellement à destination. Les deux sont nécessaires, et le second n'a
        longtemps existé que dans un script hors module, avec trois trous qui
        lui retiraient l'essentiel de sa valeur :
 
          * il n'échantillonnait que cinq éléments par bibliothèque, pris dans
            l'ordre du serveur ;
          * il ne contrôlait QUE des fichiers — jamais les dossiers, alors que
            la ré-estampille d'un dossier est le défaut le plus courant ;
          * il ne parcourait jamais la destination, donc ne voyait aucun
            élément surnuméraire.
 
        Ici : 100 % des éléments par défaut, l'échantillonnage devient une
        option explicite ; les dossiers sont inclus ; ce qui est à destination
        sans être à la source est rapporté.
 
        Et le verdict ne dit jamais « fidélité confirmée » : il dit « aucun
        écart détecté SUR LE PÉRIMÈTRE CONTRÔLÉ », suivi de ce qui n'a pas été
        contrôlé. Une vérification qui tait ses angles morts vaut moins que pas
        de vérification du tout — elle donne une confiance qu'elle n'a pas
        mesurée.
 
    .PARAMETER SampleSize
        Limite le nombre d'éléments comparés. 0 = tous, et c'est le défaut.
        Toute troncature est inscrite dans le résultat et dans le périmètre non
        contrôlé.
 
    .PARAMETER SkipVersions
        N'interroge pas l'historique des versions. Le contrôle le plus coûteux,
        et le plus structurant : ne le couper qu'en connaissance de cause.
 
    .EXAMPLE
        Test-SPMigrationFidelity -SourceUrl $src -TargetUrl $dst
 
    .OUTPUTS
        Objet { Ok, Compared, Errors, Warnings, Findings, Details, Scope }.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$SourceUrl,
        [Parameter(Mandatory, Position = 1)][string]$TargetUrl,
        $SourceConnection,
        $DestinationConnection,
        [ValidateRange(0, [int]::MaxValue)][int]$SampleSize = 0,
        [switch]$SkipVersions,
        [string]$OutputPath,
        [string]$ClientId = $env:SPM_CLIENT_ID,
        [string]$Tenant = $env:SPM_TENANT,
        [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT
    )

    $srcConn = $SourceConnection
    $dstConn = $DestinationConnection

    if (-not $srcConn -or -not $dstConn) {
        $splat = @{ ClientId = $ClientId }
        if ($Thumbprint) {
            if (-not $Tenant) { throw 'Tenant est obligatoire avec un certificat.' }
            $splat.Thumbprint = $Thumbprint; $splat.Tenant = $Tenant
        }
        else { $splat.Interactive = $true }

        if (-not $srcConn) { $srcConn = Connect-SPMSite @splat -Url (Get-SPMSiteUrlFromResourceUrl -Url $SourceUrl) }
        if (-not $dstConn) { $dstConn = Connect-SPMSite @splat -Url (Get-SPMSiteUrlFromResourceUrl -Url $TargetUrl) }
    }

    $srcPath = (Get-SPMServerRelativePath -Url $SourceUrl).TrimEnd('/')
    $dstPath = (Get-SPMServerRelativePath -Url $TargetUrl).TrimEnd('/')

    Write-SPMLog -Level INFO -Operation 'Fidelity' -Message "Comparaison de $srcPath vers $dstPath"

    Clear-SPMEnumerationFailure
    $srcItems = @(Get-SPMFolderInventory -FolderUrl $srcPath -Connection $srcConn)
    $echecsSource = @(Get-SPMEnumerationFailure)

    Clear-SPMEnumerationFailure
    $dstItems = @(Get-SPMFolderInventory -FolderUrl $dstPath -Connection $dstConn)
    $echecsDest = @(Get-SPMEnumerationFailure)

    $findings = [System.Collections.Generic.List[object]]::new()
    $rows = [System.Collections.Generic.List[object]]::new()
    $horsPerimetre = [System.Collections.Generic.List[string]]::new()

    # Un sous-arbre illisible fausse la comparaison ENTIÈRE : sans lui, des
    # éléments manquants passeraient pour absents de la source.
    foreach ($e in @($echecsSource + $echecsDest)) {
        $findings.Add([pscustomobject]@{
                Severity = 'Error'; Item = $e.FolderUrl; Aspect = 'Enumeration'
                Detail = "Dossier illisible : la comparaison de ce sous-arbre n'a pas eu lieu — $($e.Error)"
            })
    }

    $relatif = {
        param($url, $racine)
        $v = "$url"
        if ($v.StartsWith($racine, [StringComparison]::OrdinalIgnoreCase)) {
            $v = $v.Substring($racine.Length)
        }
        $v.TrimStart('/').ToLowerInvariant()
    }

    $indexDest = @{}
    foreach ($d in $dstItems) { $indexDest[(& $relatif $d.ServerRelativeUrl $dstPath)] = $d }

    $aComparer = $srcItems
    if ($SampleSize -gt 0 -and $srcItems.Count -gt $SampleSize) {
        $aComparer = $srcItems[0..($SampleSize - 1)]
        $horsPerimetre.Add(
            "$($srcItems.Count - $SampleSize) élément(s) NON comparés : échantillon de $SampleSize demandé"
        )
    }

    $vus = [System.Collections.Generic.HashSet[string]]::new()

    foreach ($src in $aComparer) {
        $cle = & $relatif $src.ServerRelativeUrl $srcPath
        [void]$vus.Add($cle)

        $ligne = [ordered]@{
            Element = $cle
            Type = if ($src.IsFolder) { 'dossier' } else { 'fichier' }
            Presence = 'OK'; Taille = '-'; Versions = '-'; Authorship = '-'; Retention = '-'
        }

        if (-not $indexDest.ContainsKey($cle)) {
            $ligne.Presence = 'ABSENT'
            $findings.Add([pscustomobject]@{
                    Severity = 'Error'; Item = $cle; Aspect = 'Presence'
                    Detail = 'Présent à la source, absent à destination'
                })
            $rows.Add([pscustomobject]$ligne)
            continue
        }

        $dst = $indexDest[$cle]

        # ------------------------------------------------------------ taille
        if (-not $src.IsFolder) {
            if ($src.SizeBytes -eq $dst.SizeBytes) { $ligne.Taille = 'OK' }
            else {
                $ligne.Taille = "$($src.SizeBytes) vs $($dst.SizeBytes)"
                $findings.Add([pscustomobject]@{
                        Severity = 'Error'; Item = $cle; Aspect = 'Contenu'
                        Detail = "Taille différente : $($src.SizeBytes) contre $($dst.SizeBytes)"
                    })
            }
        }

        # ---------------------------------------------------------- versions
        if (-not $src.IsFolder -and -not $SkipVersions) {
            $vSrc = @(Invoke-SPMWithRetry -Operation 'Get-PnPFileVersion/src' -PassThruErrors -ScriptBlock {
                    Get-PnPFileVersion -Url $src.ServerRelativeUrl -Connection $srcConn -ErrorAction Stop
                })
            $vDst = @(Invoke-SPMWithRetry -Operation 'Get-PnPFileVersion/dst' -PassThruErrors -ScriptBlock {
                    Get-PnPFileVersion -Url $dst.ServerRelativeUrl -Connection $dstConn -ErrorAction Stop
                })

            # +1 : la version courante ne figure pas dans l'historique.
            $nSrc = $vSrc.Count + 1
            $nDst = $vDst.Count + 1

            if ($nSrc -eq $nDst) {
                $ligne.Versions = "OK ($nSrc)"
                # « Versions OK (1) » ne prouve rien : un fichier sans historique
                # ne peut pas en perdre. Le dire empêche de conclure trop vite.
                if ($nSrc -le 1) { $ligne.Versions = 'OK (1 — sans historique, ne prouve rien)' }
            }
            else {
                $ligne.Versions = "$nSrc -> $nDst"
                $findings.Add([pscustomobject]@{
                        Severity = 'Error'; Item = $cle; Aspect = 'Versions'
                        Detail = "$nSrc version(s) à la source, $nDst à destination"
                    })
            }
        }
        elseif ($SkipVersions) { $ligne.Versions = 'non contrôlé' }

        # ------------------------------------------- dates, auteurs, rétention
        $srcItem = Get-SPMItemForPath -ServerRelativeUrl $src.ServerRelativeUrl `
            -Kind $(if ($src.IsFolder) { 'Folder' } else { 'File' }) -Connection $srcConn
        $dstItem = Get-SPMItemForPath -ServerRelativeUrl $dst.ServerRelativeUrl `
            -Kind $(if ($dst.IsFolder) { 'Folder' } else { 'File' }) -Connection $dstConn

        if (-not $srcItem -or -not $dstItem) {
            $ligne.Authorship = 'non lisible'
            $findings.Add([pscustomobject]@{
                    Severity = 'Warning'; Item = $cle; Aspect = 'Metadonnees'
                    Detail = 'Élément de liste non lisible d''un côté : dates et auteurs non comparés'
                })
            $rows.Add([pscustomobject]$ligne)
            continue
        }

        $ecarts = [System.Collections.Generic.List[string]]::new()
        foreach ($champ in 'Created', 'Modified', 'Author', 'Editor') {
            $a = Format-SPMFieldForCompare -Value (Get-SPMFieldValue -Item $srcItem -Name $champ)
            $b = Format-SPMFieldForCompare -Value (Get-SPMFieldValue -Item $dstItem -Name $champ)
            if ($a -ne $b) { $ecarts.Add("$champ : '$a' -> '$b'") }
        }

        $ligne.Authorship = if ($ecarts.Count) { "$($ecarts.Count) DIFF" } else { 'OK' }
        foreach ($e in $ecarts) {
            $findings.Add([pscustomobject]@{
                    Severity = 'Error'; Item = $cle; Aspect = 'Authorship'; Detail = $e
                })
        }

        # Rétention en colonne dédiée : une étiquette perdue rend un document
        # supprimable, ce qui n'est pas de même nature qu'une colonne absente.
        $confSrc = Get-SPMComplianceState -Item $srcItem
        $confDst = Get-SPMComplianceState -Item $dstItem
        $ecartsConf = [System.Collections.Generic.List[string]]::new()

        if ("$($confSrc.Label)" -ne "$($confDst.Label)") {
            $ecartsConf.Add("étiquette '$($confSrc.Label)' -> '$($confDst.Label)'")
        }
        if ($confSrc.IsRecord -and -not $confDst.IsRecord) {
            $ecartsConf.Add('enregistrement non redéclaré à destination')
        }
        if ("$($confSrc.ModerationStatus)" -ne "$($confDst.ModerationStatus)") {
            $ecartsConf.Add("modération '$($confSrc.ModerationStatus)' -> '$($confDst.ModerationStatus)'")
        }

        $ligne.Retention = if ($ecartsConf.Count) { "$($ecartsConf.Count) DIFF" } else { 'OK' }
        foreach ($e in $ecartsConf) {
            $findings.Add([pscustomobject]@{
                    Severity = 'Error'; Item = $cle; Aspect = 'Retention'; Detail = $e
                })
        }

        $rows.Add([pscustomobject]$ligne)
    }

    # ------------------------------------------- éléments SURNUMÉRAIRES
    # Une comparaison qui ne parcourt que la source ne voit jamais ce qui a été
    # copié deux fois. C'est exactement ainsi que 63 éléments source sont
    # devenus 180 à destination sans qu'aucun contrôle ne s'en aperçoive.
    if ($SampleSize -le 0) {
        foreach ($cle in $indexDest.Keys) {
            if ($vus.Contains($cle)) { continue }
            $findings.Add([pscustomobject]@{
                    Severity = 'Warning'; Item = $cle; Aspect = 'Surnumeraire'
                    Detail = 'Présent à destination, absent de la source'
                })
        }
    }
    else {
        $horsPerimetre.Add('Éléments surnuméraires à destination : non recherchés (échantillonnage actif)')
    }

    # ---------------------------------------------------- périmètre non couvert
    if ($SkipVersions) { $horsPerimetre.Add('Historique des versions : non contrôlé (-SkipVersions)') }
    $horsPerimetre.Add('Permissions uniques : non comparées ici — voir Copy-SPMigrationPermission')
    $horsPerimetre.Add('Colonnes de site, types de contenu et taxonomie : non comparés')
    $horsPerimetre.Add('Liens de partage, alertes et corbeille : hors périmètre du moteur')

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

    $resultat = [pscustomobject]@{
        Ok = ($errors.Count -eq 0)
        Compared = $rows.Count
        SourceItems = $srcItems.Count
        DestinationItems = $dstItems.Count
        Errors = $errors.Count
        Warnings = $warnings.Count
        Findings = $findings.ToArray()
        Details = $rows.ToArray()
        Scope = $horsPerimetre.ToArray()
        Truncated = ($SampleSize -gt 0 -and $srcItems.Count -gt $SampleSize)
    }

    # Le verdict ne prononce jamais « fidélité confirmée » : il borne ce qu'il a
    # regardé. Zéro comparaison n'est pas un succès — c'est une absence de
    # mesure, et c'est le résultat le plus trompeur qui soit.
    if ($rows.Count -eq 0) {
        Write-SPMLog -Level ERROR -Operation 'Fidelity' -Message (
            'AUCUN élément comparé : ce contrôle ne prouve rien. Vérifiez les URL et les droits.'
        )
        $resultat.Ok = $false
    }
    else {
        $verdict = if ($resultat.Ok) {
            "Aucun écart détecté SUR LE PÉRIMÈTRE CONTRÔLÉ — $($rows.Count) élément(s) comparé(s)"
        }
        else {
            "$($errors.Count) écart(s) détecté(s) sur $($rows.Count) élément(s) comparé(s)"
        }
        Write-SPMLog -Level $(if ($resultat.Ok) { 'SUCCESS' } else { 'ERROR' }) -Operation 'Fidelity' -Message $verdict
    }

    foreach ($h in $horsPerimetre) {
        Write-SPMLog -Level WARNING -Operation 'Fidelity' -Message "Hors périmètre contrôlé : $h"
    }

    if ($OutputPath) {
        $dir = Split-Path -Parent $OutputPath
        if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        $resultat | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $OutputPath -Encoding utf8
        Write-SPMLog -Level SUCCESS -Operation 'Fidelity' -Message "Rapport de fidélité écrit : $OutputPath"
    }

    return $resultat
}