Tests/CopilotGovernance.Tests.ps1

#Requires -Modules Pester

BeforeAll {
    $root = Split-Path $PSScriptRoot -Parent
    Import-Module (Join-Path $root 'CopilotGovernance.psd1') -Force
}

Describe 'Test-CGClaimLogin (claim matching)' {
    It 'matches Everyone by exact login' {
        InModuleScope CopilotGovernance {
            Test-CGClaimLogin -LoginName 'c:0(.s|true' -Claims (Get-CGClaim) | Should -Be 'Everyone'
        }
    }
    It 'matches EEEU by the tenant-independent fragment' {
        InModuleScope CopilotGovernance {
            Test-CGClaimLogin -LoginName 'c:0-.f|rolemanager|spo-grid-all-users/abc-123' -Claims (Get-CGClaim) |
                Should -Be 'Everyone except external users'
        }
    }
    It 'returns nothing for a normal user login' {
        InModuleScope CopilotGovernance {
            Test-CGClaimLogin -LoginName 'i:0#.f|membership|jo@contoso.com' -Claims (Get-CGClaim) | Should -BeNullOrEmpty
        }
    }
    It 'returns nothing for an empty login' {
        InModuleScope CopilotGovernance {
            Test-CGClaimLogin -LoginName '' -Claims (Get-CGClaim) | Should -BeNullOrEmpty
        }
    }
    It 'ClaimType Everyone does not match EEEU' {
        InModuleScope CopilotGovernance {
            Test-CGClaimLogin -LoginName 'c:0-.f|rolemanager|spo-grid-all-users/x' -Claims (Get-CGClaim -ClaimType Everyone) |
                Should -BeNullOrEmpty
        }
    }
}

Describe 'Get-CGLinkClass (SharingLinks classification)' {
    It 'classifies AnonymousEdit as Anonymous' {
        InModuleScope CopilotGovernance { (Get-CGLinkClass -Title 'SharingLinks.a.AnonymousEdit.b').Class | Should -Be 'Anonymous' }
    }
    It 'classifies OrganizationView as Organization' {
        InModuleScope CopilotGovernance { (Get-CGLinkClass -Title 'SharingLinks.a.OrganizationView.b').Class | Should -Be 'Organization' }
    }
    It 'classifies Flexible as Specific' {
        InModuleScope CopilotGovernance { (Get-CGLinkClass -Title 'SharingLinks.a.Flexible.b').Class | Should -Be 'Specific' }
    }
    It 'preserves the raw kind fragment' {
        InModuleScope CopilotGovernance { (Get-CGLinkClass -Title 'SharingLinks.a.OrganizationEdit.b').Kind | Should -Be 'OrganizationEdit' }
    }
    It 'handles a malformed title as Other' {
        InModuleScope CopilotGovernance { (Get-CGLinkClass -Title 'SharingLinks.only').Class | Should -Be 'Other' }
    }
}

Describe 'Get-CGReadinessScore (scoring formula)' {
    It 'is 100 for a clean tenant' {
        InModuleScope CopilotGovernance { (Get-CGReadinessScore).Score | Should -Be 100 }
    }
    It 'weights anonymous > everyone > organization' {
        InModuleScope CopilotGovernance {
            (Get-CGReadinessScore -AnonymousLinks 1).Score    | Should -Be 92
            (Get-CGReadinessScore -EveryoneGrants 1).Score    | Should -Be 96
            (Get-CGReadinessScore -OrganizationLinks 1).Score | Should -Be 98
        }
    }
    It 'reproduces the validated wayll case (3 EEEU + 1 org = 86)' {
        InModuleScope CopilotGovernance { (Get-CGReadinessScore -EveryoneGrants 3 -OrganizationLinks 1).Score | Should -Be 86 }
    }
    It 'floors at 0' {
        InModuleScope CopilotGovernance { (Get-CGReadinessScore -AnonymousLinks 100).Score | Should -Be 0 }
    }
    It 'assigns the right risk bands' {
        InModuleScope CopilotGovernance {
            (Get-CGReadinessScore).Band                     | Should -Be 'Low risk'         # 100
            (Get-CGReadinessScore -EveryoneGrants 10).Band  | Should -Be 'Needs attention'  # 60
            (Get-CGReadinessScore -AnonymousLinks 8).Band   | Should -Be 'High risk'        # 36
        }
    }
}

Describe 'New-CGScorecardHtml (report rendering)' {
    It 'writes a valid HTML file and HTML-encodes special characters' {
        InModuleScope CopilotGovernance {
            $tmp = Join-Path ([IO.Path]::GetTempPath()) 'cg-test-scorecard.html'
            $e   = @([pscustomobject]@{ Site = 'https://x/sites/r&d'; Scope = 'Site'; Object = 'x'; Claim = 'Everyone'; Permission = 'Read'; Url = 'u' })
            $sm  = @{ Score = 96; Band = 'Low risk'; Anon = 0; Org = 0; Eeeu = 1; SitesAtRisk = 1; ScopeLabel = 'https://x/sites/r&d'; Generated = 't' }
            New-CGScorecardHtml -Everyone $e -Shares @() -Summary $sm -Path $tmp
            Test-Path $tmp | Should -BeTrue
            $html = Get-Content $tmp -Raw
            $html | Should -Match 'r&d'
            $html | Should -Match '</html>'
            Remove-Item $tmp -ErrorAction SilentlyContinue
        }
    }
    It 'renders a partial-coverage warning when sites were skipped' {
        InModuleScope CopilotGovernance {
            $tmp = Join-Path ([IO.Path]::GetTempPath()) 'cg-test-coverage.html'
            $sm  = @{ Score = 100; Band = 'Low risk'; Anon = 0; Org = 0; Eeeu = 0; SitesAtRisk = 0
                     Total = 40; Scanned = 39; Skipped = 1; SkippedSites = @('https://x/sites/locked')
                     ScopeLabel = 'Whole tenant'; Generated = 't' }
            New-CGScorecardHtml -Everyone @() -Shares @() -Summary $sm -Path $tmp
            $html = Get-Content $tmp -Raw
            $html | Should -Match 'Partial coverage'
            $html | Should -Match 'scanned 39 of 40 sites'
            $html | Should -Match 'sites/locked'
            Remove-Item $tmp -ErrorAction SilentlyContinue
        }
    }
    It 'omits the coverage strip when nothing was skipped' {
        InModuleScope CopilotGovernance {
            $tmp = Join-Path ([IO.Path]::GetTempPath()) 'cg-test-nocov.html'
            $sm  = @{ Score = 100; Band = 'Low risk'; Anon = 0; Org = 0; Eeeu = 0; SitesAtRisk = 0
                     ScopeLabel = 'Whole tenant'; Generated = 't' }
            New-CGScorecardHtml -Everyone @() -Shares @() -Summary $sm -Path $tmp
            (Get-Content $tmp -Raw) | Should -Not -Match 'Partial coverage'
            Remove-Item $tmp -ErrorAction SilentlyContinue
        }
    }
}

Describe 'ConvertTo-CGGuestRecord (guest classification)' {
    It 'extracts the domain from the mail address' {
        InModuleScope CopilotGovernance {
            $g = [pscustomobject]@{ displayName='A'; mail='jo@contoso.com'; userPrincipalName='x'; externalUserState='Accepted'; createdDateTime=$null }
            (ConvertTo-CGGuestRecord -Guest $g -UseSignIn $false).ExternalDomain | Should -Be 'contoso.com'
        }
    }
    It 'parses the domain from an #EXT# UPN when mail is absent' {
        InModuleScope CopilotGovernance {
            $g = [pscustomobject]@{ displayName='A'; mail=$null; userPrincipalName='jo_contoso.com#EXT#@tenant.onmicrosoft.com'; externalUserState='Accepted'; createdDateTime=$null }
            (ConvertTo-CGGuestRecord -Guest $g -UseSignIn $false).ExternalDomain | Should -Be 'contoso.com'
        }
    }
    It 'flags an unaccepted invite as stale when no sign-in data' {
        InModuleScope CopilotGovernance {
            $g = [pscustomobject]@{ displayName='A'; mail='jo@x.com'; userPrincipalName='x'; externalUserState='PendingAcceptance'; createdDateTime=$null }
            (ConvertTo-CGGuestRecord -Guest $g -UseSignIn $false).Stale | Should -BeTrue
        }
    }
    It 'does not flag an accepted invite as stale when no sign-in data' {
        InModuleScope CopilotGovernance {
            $g = [pscustomobject]@{ displayName='A'; mail='jo@x.com'; userPrincipalName='x'; externalUserState='Accepted'; createdDateTime=$null }
            (ConvertTo-CGGuestRecord -Guest $g -UseSignIn $false).Stale | Should -BeFalse
        }
    }
    It 'flags a never-signed-in guest as stale when sign-in data is available' {
        InModuleScope CopilotGovernance {
            $g = [pscustomobject]@{ displayName='A'; mail='jo@x.com'; userPrincipalName='x'; externalUserState='Accepted'; createdDateTime=$null }
            $r = ConvertTo-CGGuestRecord -Guest $g -SignInActivity $null -UseSignIn $true
            $r.NeverSignedIn | Should -BeTrue
            $r.Stale         | Should -BeTrue
        }
    }
    It 'flags long-idle stale and recent not-stale by sign-in date' {
        InModuleScope CopilotGovernance {
            $now = Get-Date
            $g   = [pscustomobject]@{ displayName='A'; mail='jo@x.com'; userPrincipalName='x'; externalUserState='Accepted'; createdDateTime=$null }
            $old = [pscustomobject]@{ lastSignInDateTime = $now.AddDays(-200).ToString('o') }
            $new = [pscustomobject]@{ lastSignInDateTime = $now.AddDays(-5).ToString('o') }
            (ConvertTo-CGGuestRecord -Guest $g -SignInActivity $old -UseSignIn $true -StaleDays 90 -Now $now).Stale | Should -BeTrue
            (ConvertTo-CGGuestRecord -Guest $g -SignInActivity $new -UseSignIn $true -StaleDays 90 -Now $now).Stale | Should -BeFalse
        }
    }
}

Describe 'Auth mode selection (delegated vs app-only)' {
    BeforeEach { InModuleScope CopilotGovernance { $script:CGAuth = $null } }

    It 'Connect-CopilotGovernance uses interactive sign-in by default and records AppOnly false' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            Connect-CopilotGovernance -Url 'https://contoso-admin.sharepoint.com' -ClientId 'cid-1'
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $Interactive -eq $true -and $ClientId -eq 'cid-1' }
            $script:CGAuth.AppOnly | Should -BeFalse
        }
    }

    It 'Connect-CopilotGovernance uses the certificate thumbprint for app-only and records AppOnly true' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            Connect-CopilotGovernance -Url 'https://contoso-admin.sharepoint.com' -ClientId 'cid-1' -Tenant 'contoso.onmicrosoft.com' -Thumbprint 'ABC123'
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $Thumbprint -eq 'ABC123' -and $Tenant -eq 'contoso.onmicrosoft.com' -and -not $Interactive }
            $script:CGAuth.AppOnly | Should -BeTrue
        }
    }

    It 'Connect-CGSite reconnects app-only when the stored context is app-only' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            $script:CGAuth = @{ ClientId='cid'; Tenant='contoso.onmicrosoft.com'; Thumbprint='ABC123'; AppOnly=$true }
            Connect-CGSite -Url 'https://contoso.sharepoint.com/sites/hr'
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $Thumbprint -eq 'ABC123' -and -not $Interactive }
        }
    }

    It 'Connect-CGSite falls back to interactive with a passed ClientId when no context is stored' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            $script:CGAuth = $null
            Connect-CGSite -Url 'https://contoso.sharepoint.com/sites/hr' -ClientId 'cid-2'
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $Interactive -eq $true -and $ClientId -eq 'cid-2' }
        }
    }

    It 'Connect-CopilotGovernance app-only with a .pfx passes CertificatePath and password, not Interactive' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            $pw = [System.Security.SecureString]::new()
            Connect-CopilotGovernance -Url 'https://contoso-admin.sharepoint.com' -ClientId 'cid-1' -Tenant 'contoso.onmicrosoft.com' -CertificatePath 'C:\certs\cg.pfx' -CertificatePassword $pw
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $CertificatePath -eq 'C:\certs\cg.pfx' -and $CertificatePassword -and -not $Interactive -and -not $Thumbprint }
            $script:CGAuth.AppOnly | Should -BeTrue
        }
    }

    It 'delegated connect records no certificate details' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            Connect-CopilotGovernance -Url 'https://contoso.sharepoint.com/sites/hr' -ClientId 'cid-1'
            $script:CGAuth.Thumbprint      | Should -BeNullOrEmpty
            $script:CGAuth.CertificatePath | Should -BeNullOrEmpty
            $script:CGAuth.AppOnly         | Should -BeFalse
        }
    }

    It 'Connect-CGSite with a delegated stored context reconnects interactively using the stored client id' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            $script:CGAuth = @{ ClientId='cid-stored'; AppOnly=$false }
            Connect-CGSite -Url 'https://contoso.sharepoint.com/sites/hr'
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $Interactive -eq $true -and $ClientId -eq 'cid-stored' }
        }
    }

    It 'Connect-CGSite with a .pfx app-only context passes CertificatePath' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            $script:CGAuth = @{ ClientId='cid'; Tenant='contoso.onmicrosoft.com'; CertificatePath='C:\certs\cg.pfx'; AppOnly=$true }
            Connect-CGSite -Url 'https://contoso.sharepoint.com/sites/hr'
            Should -Invoke Connect-PnPOnline -Times 1 -Exactly -ParameterFilter { $CertificatePath -eq 'C:\certs\cg.pfx' -and -not $Interactive -and -not $Thumbprint }
        }
    }

    It 'Connect-CGSite throws a clear error when there is no context and no client id' {
        InModuleScope CopilotGovernance {
            Mock Connect-PnPOnline { }
            $script:CGAuth = $null
            { Connect-CGSite -Url 'https://contoso.sharepoint.com/sites/hr' } | Should -Throw '*Connect-CopilotGovernance*'
            Should -Invoke Connect-PnPOnline -Times 0 -Exactly
        }
    }
}