tests/SharingLinkAudit.Tests.ps1

#requires -Module Pester

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

Describe 'Module surface' {
    It 'exports exactly the three public functions' {
        $exported = (Get-Command -Module SharingLinkAudit).Name | Sort-Object
        $exported | Should -Be @('Export-SharingLinkReport', 'Get-SharingLink', 'Remove-SharingLink')
    }
    It 'every public function has comment-based help' {
        foreach ($fn in 'Get-SharingLink','Remove-SharingLink','Export-SharingLinkReport') {
            (Get-Help $fn).Synopsis | Should -Not -BeNullOrEmpty
        }
    }
}

Describe 'Get-LinkRisk - the risk ladder' {
    # Private function, so reach into the module scope to test it directly.
    It 'rates a specific-person view link as Low' {
        InModuleScope SharingLinkAudit {
            Get-LinkRisk -Scope 'Users' -LinkType 'View' -Expiration $null -HasPassword $false |
                Should -Be 'Low'
        }
    }
    It 'rates an organization view link as Medium' {
        InModuleScope SharingLinkAudit {
            Get-LinkRisk -Scope 'Organization' -LinkType 'View' -Expiration $null -HasPassword $false |
                Should -Be 'Medium'
        }
    }
    It 'rates an organization edit link with no expiry as High' {
        InModuleScope SharingLinkAudit {
            Get-LinkRisk -Scope 'Organization' -LinkType 'Edit' -Expiration $null -HasPassword $false |
                Should -Be 'High'
        }
    }
    It 'rates an anonymous no-password edit link as Critical' {
        InModuleScope SharingLinkAudit {
            Get-LinkRisk -Scope 'Anonymous' -LinkType 'Edit' -Expiration $null -HasPassword $false |
                Should -Be 'Critical'
        }
    }
    It 'an expiry date lowers the risk band of a broad link' {
        # Anonymous view, no password: crosses Critical -> High once an expiry is set.
        InModuleScope SharingLinkAudit {
            $withoutExpiry = Get-LinkRisk -Scope 'Anonymous' -LinkType 'View' -Expiration $null -HasPassword $false
            $withExpiry    = Get-LinkRisk -Scope 'Anonymous' -LinkType 'View' -Expiration (Get-Date) -HasPassword $false
            $withoutExpiry | Should -Be 'Critical'
            $withExpiry    | Should -Be 'High'
        }
    }
    It 'expiry does not change a specific-person link (it was already low risk)' {
        InModuleScope SharingLinkAudit {
            Get-LinkRisk -Scope 'Users' -LinkType 'View' -Expiration $null       -HasPassword $false | Should -Be 'Low'
            Get-LinkRisk -Scope 'Users' -LinkType 'View' -Expiration (Get-Date)  -HasPassword $false | Should -Be 'Low'
        }
    }
    It 'a password lowers the risk of an anonymous link' {
        InModuleScope SharingLinkAudit {
            $noPw = Get-LinkRisk -Scope 'Anonymous' -LinkType 'View' -Expiration $null -HasPassword $false
            $pw   = Get-LinkRisk -Scope 'Anonymous' -LinkType 'View' -Expiration $null -HasPassword $true
            # no-password anonymous view = 3+1(noexp)+1(nopw)=5 Critical; with pw = 4 High
            $noPw | Should -Be 'Critical'
            $pw   | Should -Be 'High'
        }
    }
}

Describe 'Export-SharingLinkReport' {
    BeforeAll {
        $script:sample = @(
            [pscustomobject]@{ SiteUrl='x'; SiteTitle='Sales'; ItemType='File'; ItemName='Budget.xlsx'; Label=$null; Scope='Anonymous'; Access='Edit'; Recipients='Anyone with the link'; Expiration=$null; HasPassword=$false; BlocksDownload=$false; RiskLevel='Critical'; WebUrl='https://x'; LinkId='1' }
            [pscustomobject]@{ SiteUrl='x'; SiteTitle='HR'; ItemType='File'; ItemName='Pay & Bonus.docx'; Label='Confidential'; Scope='Organization'; Access='View'; Recipients='Everyone'; Expiration=(Get-Date).AddDays(30); HasPassword=$false; BlocksDownload=$true; RiskLevel='Medium'; WebUrl='https://y'; LinkId='2' }
        )
        $script:tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("slatest_" + [System.IO.Path]::GetRandomFileName())
        New-Item -ItemType Directory -Path $script:tmp | Out-Null
    }
    AfterAll { Remove-Item $script:tmp -Recurse -Force -ErrorAction SilentlyContinue }

    It 'writes a CSV with one row per link' {
        $csv = Join-Path $script:tmp 'r.csv'
        $script:sample | Export-SharingLinkReport -Path $csv
        (Import-Csv $csv).Count | Should -Be 2
    }
    It 'writes self-contained, well-formed HTML' {
        $htmlPath = Join-Path $script:tmp 'r.html'
        $script:sample | Export-SharingLinkReport -Path $htmlPath -Html
        $content = Get-Content $htmlPath -Raw
        $content | Should -Match '<!doctype html>'
        $content.TrimEnd() | Should -Match '</html>$'
        $content | Should -Not -Match 'src="http'   # no external assets
    }
    It 'HTML-encodes special characters in item names (no injection)' {
        $htmlPath = Join-Path $script:tmp 'enc.html'
        $script:sample | Export-SharingLinkReport -Path $htmlPath -Html
        $content = Get-Content $htmlPath -Raw
        $content | Should -Match 'Pay &amp; Bonus'
        $content | Should -Not -Match 'Pay & Bonus\.docx'   # raw & would mean unencoded
    }
    It 'warns and writes nothing when there are no links' {
        $empty = Join-Path $script:tmp 'empty.csv'
        @() | Export-SharingLinkReport -Path $empty -WarningAction SilentlyContinue
        Test-Path $empty | Should -BeFalse
    }
}

Describe 'Remove-SharingLink guards' {
    It 'skips objects that lack ItemPath/LinkId' {
        InModuleScope SharingLinkAudit {
            Mock Remove-PnPFileSharingLink {}
            Mock Remove-PnPFolderSharingLink {}
            [pscustomobject]@{ ItemName = 'junk' } |
                Remove-SharingLink -Confirm:$false -WarningAction SilentlyContinue
            Should -Invoke Remove-PnPFileSharingLink -Times 0
        }
    }
    It '-WhatIf revokes nothing' {
        InModuleScope SharingLinkAudit {
            Mock Remove-PnPFileSharingLink {}
            [pscustomobject]@{ ItemType='File'; ItemName='f'; ItemPath='/x'; LinkId='1'; Scope='Anonymous'; Access='Edit' } |
                Remove-SharingLink -WhatIf
            Should -Invoke Remove-PnPFileSharingLink -Times 0
        }
    }
    It 'calls the file cmdlet for a file link when confirmed' {
        InModuleScope SharingLinkAudit {
            Mock Remove-PnPFileSharingLink {}
            Mock Invoke-WithRetry { & $Action }   # run the action inline in tests
            [pscustomobject]@{ ItemType='File'; ItemName='f'; ItemPath='/x'; LinkId='1'; Scope='Anonymous'; Access='Edit' } |
                Remove-SharingLink -Confirm:$false
            Should -Invoke Remove-PnPFileSharingLink -Times 1
        }
    }
    It 'calls the folder cmdlet for a folder link' {
        InModuleScope SharingLinkAudit {
            Mock Remove-PnPFolderSharingLink {}
            Mock Invoke-WithRetry { & $Action }
            [pscustomobject]@{ ItemType='Folder'; ItemName='fld'; ItemPath='/x'; LinkId='1'; Scope='Organization'; Access='View' } |
                Remove-SharingLink -Confirm:$false
            Should -Invoke Remove-PnPFolderSharingLink -Times 1
        }
    }
}

Describe 'Get-SharingLink user-facing messages' {
    It 'says so, professionally, when a site has no links' {
        InModuleScope SharingLinkAudit {
            Mock Connect-IfNeeded {}
            Mock Get-SiteSharingLink { }   # no links
            # capture the information stream (6)
            $info = Get-SharingLink -SiteUrl 'https://x/sites/Empty' -UseExistingConnection 6>&1
            "$info" | Should -Match 'No sharing links found'
        }
    }
    It 'emits link objects (not a message) when links exist' {
        InModuleScope SharingLinkAudit {
            Mock Connect-IfNeeded {}
            Mock Get-SiteSharingLink {
                [pscustomobject]@{ SiteUrl='x';SiteTitle='S';ItemType='File';ItemName='a';Label=$null;Scope='Anonymous';Access='Edit';Recipients='Anyone';Expiration=$null;HasPassword=$false;BlocksDownload=$false;RiskLevel='Critical';WebUrl='u';LinkId='1' }
            }
            $r = Get-SharingLink -SiteUrl 'https://x/sites/Full' -UseExistingConnection
            $r.Count | Should -Be 1
            $r.RiskLevel | Should -Be 'Critical'
        }
    }
}

Describe 'Invoke-WithRetry' {
    It 'returns the action result on success without retrying' {
        InModuleScope SharingLinkAudit {
            $script:calls = 0
            $r = Invoke-WithRetry -Action { $script:calls++; 'ok' }
            $r | Should -Be 'ok'
            $script:calls | Should -Be 1
        }
    }
    It 'rethrows a non-throttle error immediately' {
        InModuleScope SharingLinkAudit {
            $script:calls = 0
            { Invoke-WithRetry -Action { $script:calls++; throw 'File not found' } } | Should -Throw
            $script:calls | Should -Be 1   # no retry on a non-throttle error
        }
    }
}