Public/Test-SPMTenantCapability.ps1

function Test-SPMTenantCapability {
    <#
    .SYNOPSIS
        Mesure ce que le tenant AUTORISE, au lieu de le deviner.
 
    .DESCRIPTION
        Le pré-vol énumère, vérifie les noms, les tailles et les longueurs d'URL,
        mais n'écrit rien : il ne dit jamais si la copie serveur fonctionne sur ce
        tenant, ni si le compte peut y créer une bibliothèque. Toute une session a
        été perdue à confondre un bug d'appel client avec un refus du service —
        faute de cette mesure.
 
        Deux étages, séparés parce qu'ils n'engagent pas la même chose :
 
          * LECTURE SEULE, toujours. Connexions, droits effectifs à destination
            (créer une bibliothèque, ajouter des éléments, appliquer un modèle de
            site), présence de la bibliothèque cible.
 
          * SONDE DE COPIE, seulement sur -ProbeCopy. Crée un dossier temporaire
            à destination, y copie le plus petit fichier de la source, supprime le
            dossier. C'est le SEUL moyen de savoir si l'API de travaux de copie
            est autorisée — la question qui décide de tout le reste, et qui coûte
            trente secondes.
 
        Les trois opérations de la sonde sont jugées séparément. Les confondre
        faisait lire « Accès refusé » comme un bridage de l'API de copie alors que
        le refus portait sur la création du dossier : un diagnostic radicalement
        différent, et une conclusion fausse sur la préservation des versions.
 
    .PARAMETER SourceUrl
        URL de la bibliothèque ou du dossier source.
 
    .PARAMETER TargetUrl
        URL du dossier de destination.
 
    .PARAMETER ProbeCopy
        Exécute la sonde de copie. SEUL mode qui écrit — dans un dossier
        temporaire supprimé ensuite.
 
    .EXAMPLE
        Test-SPMTenantCapability -SourceUrl $src -TargetUrl $dst -ProbeCopy
 
    .OUTPUTS
        Objet { Ok, ServerCopy, CanCreateList, CanApplySiteTemplate,
                RecommendedTransfer, RecommendedSchemaMethod, Checks }.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$SourceUrl,
        [Parameter(Mandatory, Position = 1)][string]$TargetUrl,
        $SourceConnection,
        $DestinationConnection,
        [switch]$ProbeCopy,
        [string]$ClientId = $env:SPM_CLIENT_ID,
        [string]$Tenant = $env:SPM_TENANT,
        [string]$Thumbprint = $env:SPM_CERT_THUMBPRINT
    )

    $checks = [System.Collections.Generic.List[object]]::new()
    $ajout = {
        param([string]$Nom, [string]$Etat, [string]$Detail)
        $checks.Add([pscustomobject]@{ Check = $Nom; State = $Etat; Detail = $Detail })
        $niveau = switch ($Etat) { 'OK' { 'SUCCESS' } 'ECHEC' { 'ERROR' } 'IGNORE' { 'DEBUG' } default { 'WARNING' } }
        Write-SPMLog -Level $niveau -Operation 'Capability' -Message "$Nom : $Etat — $Detail"
    }

    # ------------------------------------------------------------- connexions
    if (-not $DestinationConnection -or (-not $SourceConnection)) {
        $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 $SourceConnection) {
            $SourceConnection = Connect-SPMSite @splat -Url (Get-SPMSiteUrlFromResourceUrl -Url $SourceUrl)
        }
        if (-not $DestinationConnection) {
            $DestinationConnection = Connect-SPMSite @splat -Url (Get-SPMSiteUrlFromResourceUrl -Url $TargetUrl)
        }
    }
    & $ajout 'Connexions' 'OK' 'source et destination ouvertes'

    $srcPath = Get-SPMServerRelativePath -Url $SourceUrl
    $dstPath = Get-SPMServerRelativePath -Url $TargetUrl

    # ------------------------------------------------------- droits effectifs
    # Mesurer les droits AVANT d'écrire coûte un appel et évite de conclure
    # « API refusée » sur un compte qui n'a simplement pas le droit de créer.
    # Un droit non déterminé reste $null : « inconnu » n'est pas « refusé ».
    $peutCreerListe = $null
    $peutModeleDeSite = $null
    try {
        $web = Invoke-SPMWithRetry -Operation 'web/capability' -ScriptBlock {
            Get-PnPWeb -Includes EffectiveBasePermissions -Connection $DestinationConnection -ErrorAction Stop
        }
        $perms = $web.PSObject.Properties['EffectiveBasePermissions'] ? $web.EffectiveBasePermissions : $null

        if ($perms -and $perms.PSObject.Methods['Has']) {
            $peutCreerListe = [bool]$perms.Has('ManageLists')
            $peutModeleDeSite = [bool]$perms.Has('ManageWeb')

            $etatListe = if ($peutCreerListe) { 'OK' } else { 'ECHEC' }
            $detailListe = if ($peutCreerListe) { 'droit ManageLists accorde' }
            else { 'droit ManageLists absent : Copy-SPSiteSchema echouerait' }
            & $ajout 'Creation de bibliotheque' $etatListe $detailListe

            $etatModele = if ($peutModeleDeSite) { 'OK' } else { 'AVERTISSEMENT' }
            $detailModele = if ($peutModeleDeSite) { 'droit ManageWeb accorde' }
            else { 'droit ManageWeb absent : Invoke-PnPSiteTemplate echouerait' }
            & $ajout 'Modele de site' $etatModele $detailModele
        }
        else {
            & $ajout 'Droits effectifs' 'IGNORE' 'permissions non lisibles sur ce tenant : ni accordees ni refusees'
        }
    }
    catch {
        & $ajout 'Droits effectifs' 'IGNORE' ($_.Exception.Message -split [char]10)[0]
    }

    # ------------------------------------------- bibliotheque de destination
    $libTitle = $null
    try {
        $libTitle = Get-SPMListTitleForPath -ServerRelativeUrl $dstPath -Connection $DestinationConnection
    }
    catch { $libTitle = $null }

    $destinationPrete = [bool]$libTitle
    $etatDest = if ($destinationPrete) { 'OK' } else { 'ECHEC' }
    $detailDest = if ($destinationPrete) { "« $libTitle »" } else { "aucune liste ne porte $dstPath" }
    & $ajout 'Bibliotheque de destination' $etatDest $detailDest

    # ------------------------------------------------------- sonde de copie
    $copieServeur = 'non testé'

    if (-not $ProbeCopy) {
        & $ajout 'Copie serveur-a-serveur' 'IGNORE' 'relancez avec -ProbeCopy pour trancher'
    }
    elseif (-not $destinationPrete) {
        # Créer un dossier sous un chemin qui n'appartient à aucune liste donne
        # « Accès refusé », qu'on lirait alors comme un bridage de l'API de
        # copie. Ce faux signal a coûté assez cher pour qu'on l'empêche.
        & $ajout 'Copie serveur-a-serveur' 'IGNORE' 'destination absente : la sonde ne prouverait rien'
    }
    else {
        Clear-SPMEnumerationFailure
        $items = @(Get-SPMFolderInventory -FolderUrl $srcPath -Connection $SourceConnection -MaxDepth 1)
        $candidats = @($items | Where-Object { -not $_.IsFolder -and $_.SizeBytes -gt 0 })
        if (-not $candidats.Count) { $candidats = @($items | Where-Object { -not $_.IsFolder }) }
        $cobaye = $candidats | Sort-Object SizeBytes | Select-Object -First 1

        if (-not $cobaye) {
            & $ajout 'Copie serveur-a-serveur' 'IGNORE' 'aucun fichier a la source'
        }
        elseif (-not $PSCmdlet.ShouldProcess($TargetUrl, 'Sonder la copie serveur (dossier temporaire)')) {
            & $ajout 'Copie serveur-a-serveur' 'IGNORE' 'sonde simulee'
        }
        else {
            $sondeNom = "_sonde_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
            $sondeServeur = "$dstPath/$sondeNom"
            $sondeSiteRel = $null
            $dossierCree = $false

            try {
                $sondeSiteRel = Get-SPMSiteRelativePath -ServerRelativeUrl $sondeServeur -Connection $DestinationConnection
                Invoke-SPMWithRetry -Operation 'Resolve-PnPFolder/sonde' -MaxRetries 0 -ScriptBlock {
                    Resolve-PnPFolder -SiteRelativePath $sondeSiteRel -Connection $DestinationConnection -ErrorAction Stop
                } | Out-Null
                $dossierCree = $true
                & $ajout 'Creation du dossier sonde' 'OK' $sondeSiteRel
            }
            catch {
                & $ajout 'Creation du dossier sonde' 'ECHEC' "$(($_.Exception.Message -split [char]10)[0]) (chemin : $sondeSiteRel)"
            }

            if ($dossierCree) {
                try {
                    Invoke-SPMWithRetry -Operation 'Copy-PnPFile/sonde' -MaxRetries 0 -ScriptBlock {
                        Copy-PnPFile -SourceUrl $cobaye.ServerRelativeUrl -TargetUrl $sondeServeur `
                            -Force -Connection $SourceConnection -ErrorAction Stop
                    } | Out-Null
                    $copieServeur = 'oui'
                    & $ajout 'Copie serveur-a-serveur' 'OK' "autorisee sur « $($cobaye.Name) » — versions recuperables"
                }
                catch {
                    $msg = ($_.Exception.Message -split [char]10)[0]
                    if ($msg -match 'Unauthorized|Access denied|Acces refuse|Accès refusé|403') {
                        $copieServeur = 'non'
                        & $ajout 'Copie serveur-a-serveur' 'ECHEC' 'REFUSEE par le tenant — versions non recuperables sans repli'
                    }
                    else {
                        # Un échec qui n'est pas un refus ne prouve rien sur
                        # l'API : le dire « refusée » condamnerait à tort la
                        # préservation des versions.
                        & $ajout 'Copie serveur-a-serveur' 'AVERTISSEMENT' "echec non concluant : $msg"
                    }
                }

                try {
                    $parent = ''
                    if ($sondeSiteRel.Contains('/')) {
                        $parent = $sondeSiteRel.Substring(0, $sondeSiteRel.LastIndexOf('/'))
                    }
                    Invoke-SPMWithRetry -Operation 'Remove-PnPFolder/sonde' -MaxRetries 0 -ScriptBlock {
                        Remove-PnPFolder -Name $sondeNom -Folder $parent -Force `
                            -Connection $DestinationConnection -ErrorAction Stop
                    } | Out-Null
                    & $ajout 'Suppression du dossier sonde' 'OK' 'destination laissee intacte'
                }
                catch {
                    & $ajout 'Suppression du dossier sonde' 'AVERTISSEMENT' `
                        "a supprimer a la main : $sondeServeur"
                }
            }
        }
    }

    $echecs = @($checks | Where-Object State -eq 'ECHEC')

    [pscustomobject]@{
        Ok = ($echecs.Count -eq 0)
        ServerCopy = $copieServeur
        # La soumission part de la SOURCE : CreateCopyJobs s'invoque sur le site
        # qui détient le contenu à exporter. Demander à la cible d'exporter un
        # contenu qu'elle n'a pas était le défaut d'origine du moteur.
        SubmitFrom = 'source'
        CanCreateList = $peutCreerListe
        CanApplySiteTemplate = $peutModeleDeSite
        DestinationLibrary = $libTitle
        RecommendedTransfer = switch ($copieServeur) {
            'oui' { 'copie serveur — historique préservé' }
            'non' { 'repli local obligatoire — HISTORIQUE PERDU' }
            default { 'inconnu — sonde non exécutée' }
        }
        RecommendedSchemaMethod = if ($peutModeleDeSite) { 'Copy-SPSiteStructure (modèle de site)' }
        elseif ($peutCreerListe) { 'Copy-SPSiteSchema (création directe)' }
        else { 'inconnu — droits non mesurés' }
        Checks = $checks.ToArray()
        Failures = $echecs.Count
    }
}