Tests/Unit/LicenseManager.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { # Dot-source in dependency order — Private first, then Public . (Join-Path $PSScriptRoot '../../Private/LicenseManager.ps1') . (Join-Path $PSScriptRoot '../../Public/License/Get-SPCLicenseInfo.ps1') . (Join-Path $PSScriptRoot '../../Public/License/Register-SPCLicense.ps1') # Fixed test secret — 32 bytes [1..32] $script:TestSecret = [byte[]]@(1..32) # Helper: build a valid signed license key using the test secret function New-TestLicenseKey { param( [string] $Tier = 'PRO', [string] $Email = 'test@contoso.com', [int] $ExpiryOffset = 365, [string] $LicenseId = 'test1234', [byte[]] $SecretBytes = ([byte[]]@(1..32)) ) $expiry = [datetime]::UtcNow.AddDays($ExpiryOffset).ToString('o') $issuedAt = [datetime]::UtcNow.ToString('o') $payload = [ordered]@{ tier = $Tier expiry = $expiry licenseId = $LicenseId email = $Email issuedAt = $issuedAt } | ConvertTo-Json -Compress $payloadB64 = [System.Convert]::ToBase64String( [System.Text.Encoding]::UTF8.GetBytes($payload) ).Replace('+', '-').Replace('/', '_').TrimEnd('=') $hmac = [System.Security.Cryptography.HMACSHA256]::new($SecretBytes) $sigRaw = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payloadB64)) $hmac.Dispose() $sigB64 = [System.Convert]::ToBase64String($sigRaw).Replace('+', '-').Replace('/', '_').TrimEnd('=') "SPCLEAN-$Tier-$payloadB64-$sigB64" } } # ════════════════════════════════════════════════════════════════════════════ Describe 'Test-SPCLicenseKey' { Context 'Valid PRO key' { It 'AC-LIC-01: returns IsValid=true for a valid PRO key' { $key = New-TestLicenseKey -Tier 'PRO' $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeTrue } It 'AC-LIC-01: Tier field is PRO' { $key = New-TestLicenseKey -Tier 'PRO' $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.Tier | Should -Be 'PRO' } It 'AC-LIC-01: Email field is populated' { $key = New-TestLicenseKey -Tier 'PRO' -Email 'alice@contoso.com' $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.Email | Should -Be 'alice@contoso.com' } It 'AC-LIC-01: LicenseId field is populated' { $key = New-TestLicenseKey -Tier 'PRO' -LicenseId 'abc12345' $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.LicenseId | Should -Be 'abc12345' } It 'AC-LIC-01: ExpiresAt is in the future' { $key = New-TestLicenseKey -Tier 'PRO' -ExpiryOffset 365 $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.ExpiresAt | Should -BeGreaterThan ([datetime]::UtcNow) } It 'returns TypeName SPC.LicenseValidation' { $key = New-TestLicenseKey $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.PSObject.TypeNames | Should -Contain 'SPC.LicenseValidation' } It 'tolerates leading/trailing whitespace in key' { $key = " $(New-TestLicenseKey -Tier 'PRO') " $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeTrue } } Context 'Valid CONSULTANT key' { It 'returns IsValid=true for CONSULTANT tier' { $key = New-TestLicenseKey -Tier 'CONSULTANT' $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeTrue $result.Tier | Should -Be 'CONSULTANT' } } Context 'AC-LIC-04: Tampered signature' { It 'AC-LIC-04: returns SignatureMismatch when last chars are changed' { $key = New-TestLicenseKey -Tier 'PRO' $tampered = $key.Substring(0, $key.Length - 3) + 'AAA' $result = Test-SPCLicenseKey -LicenseKey $tampered -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'SignatureMismatch' } It 'AC-LIC-04: returns SignatureMismatch when payload is changed' { $key = New-TestLicenseKey -Tier 'PRO' $parts = $key -split '-', 3 # Flip a char in the middle of the payload $payload = $parts[2].Substring(0, 10) + 'X' + $parts[2].Substring(11) $tampered = "SPCLEAN-PRO-$payload" $result = Test-SPCLicenseKey -LicenseKey $tampered -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -BeIn @('SignatureMismatch', 'MalformedFormat', 'Base64DecodeFailure') } } Context 'AC-LIC-05: Expired key' { It 'AC-LIC-05: returns Expired for a key expiring yesterday' { $key = New-TestLicenseKey -ExpiryOffset -1 $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'Expired' } } Context 'AC-LIC-06: Malformed format' { It 'AC-LIC-06: returns MalformedFormat for plain string' { $result = Test-SPCLicenseKey -LicenseKey 'NOTAKEY' -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'MalformedFormat' } It 'returns MalformedFormat for wrong prefix' { $result = Test-SPCLicenseKey -LicenseKey 'WRONGPFX-PRO-payload-sig' -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'MalformedFormat' } It 'returns MalformedFormat for two-part key' { $result = Test-SPCLicenseKey -LicenseKey 'SPCLEAN-PRO' -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'MalformedFormat' } } Context 'Invalid tier' { It 'returns InvalidTier for FREE tier' { $result = Test-SPCLicenseKey -LicenseKey 'SPCLEAN-FREE-payload-sig' -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'InvalidTier' } It 'returns InvalidTier for unknown tier' { $result = Test-SPCLicenseKey -LicenseKey 'SPCLEAN-ENTERPRISE-payload-sig' -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeFalse $result.FailureReason | Should -Be 'InvalidTier' } } Context 'Base64URL edge cases' { It 'handles key with no padding (length mod 4 = 0)' { # Any valid key from New-TestLicenseKey exercises this path $key = New-TestLicenseKey $result = Test-SPCLicenseKey -LicenseKey $key -SecretKeyBytes $script:TestSecret $result.IsValid | Should -BeTrue } } Context 'Constant-time compare helper' { It 'returns true for identical arrays' { Compare-ByteArrayConstantTimeInternal -A @(1, 2, 3) -B @(1, 2, 3) | Should -BeTrue } It 'returns false for different arrays of same length' { Compare-ByteArrayConstantTimeInternal -A @(1, 2, 3) -B @(1, 2, 4) | Should -BeFalse } It 'returns false for arrays of different lengths' { Compare-ByteArrayConstantTimeInternal -A @(1, 2) -B @(1, 2, 3) | Should -BeFalse } } } # ════════════════════════════════════════════════════════════════════════════ Describe 'Get-SPCLicenseInfo' { BeforeEach { # Ensure fresh state for each test $script:SPCLicenseCache = $null } Context 'No license file' { It 'AC-LIC-02 pre: returns Unlicensed when no file exists' { Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $false } $result = Get-SPCLicenseInfo $result.Status | Should -Be 'Unlicensed' } It 'returns Tier=FREE when unlicensed' { Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $false } $result = Get-SPCLicenseInfo $result.Tier | Should -Be 'FREE' } It 'never throws when no file exists' { Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $false } { Get-SPCLicenseInfo } | Should -Not -Throw } } Context 'Valid license on disk' { It 'returns Active when license.lic contains a valid key' { $key = New-TestLicenseKey -Tier 'PRO' $licJson = [ordered]@{ licenseKey = $key tier = 'PRO' email = 'test@contoso.com' licenseId = 'test1234' expiresAt = [datetime]::UtcNow.AddDays(365).ToString('o') issuedAt = [datetime]::UtcNow.ToString('o') registeredAt = [datetime]::UtcNow.ToString('o') } | ConvertTo-Json -Compress Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $true } Mock Get-Content -ParameterFilter { $Path -like '*SPClean*license.lic' } { $licJson } Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } $result = Get-SPCLicenseInfo $result.Status | Should -Be 'Active' $result.Tier | Should -Be 'PRO' } } Context 'AC-LIC-03: Cache behavior' { It 'AC-LIC-03: returns from cache on second call without disk read' { $key = New-TestLicenseKey -Tier 'PRO' $licJson = [ordered]@{ licenseKey = $key tier = 'PRO' email = 'test@contoso.com' licenseId = 'test1234' expiresAt = [datetime]::UtcNow.AddDays(365).ToString('o') issuedAt = [datetime]::UtcNow.ToString('o') registeredAt = [datetime]::UtcNow.ToString('o') } | ConvertTo-Json -Compress Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $true } Mock Get-Content -ParameterFilter { $Path -like '*SPClean*license.lic' } { $licJson } Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } $null = Get-SPCLicenseInfo # prime cache $null = Get-SPCLicenseInfo # should use cache # Get-Content should have been called exactly once (disk read happened once) Should -Invoke Get-Content -Exactly 1 -ParameterFilter { $Path -like '*SPClean*license.lic' } } It 'cache is cleared when SPCLicenseCache is set to null' { $script:SPCLicenseCache = $null Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $false } $result = Get-SPCLicenseInfo $result.Status | Should -Be 'Unlicensed' } } Context 'AC-LIC-13: Corrupted license.lic' { It 'AC-LIC-13: returns Invalid status — does not throw' { Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $true } Mock Get-Content -ParameterFilter { $Path -like '*SPClean*license.lic' } { 'not valid json {{' } $result = Get-SPCLicenseInfo $result.Status | Should -Be 'Invalid' } It 'AC-LIC-13: returns Invalid when licenseKey field is missing' { Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $true } Mock Get-Content -ParameterFilter { $Path -like '*SPClean*license.lic' } { '{"tier":"PRO"}' } $result = Get-SPCLicenseInfo $result.Status | Should -Be 'Invalid' } } Context 'Expired license on disk' { It 'returns Expired when key is past expiry' { $key = New-TestLicenseKey -Tier 'PRO' -ExpiryOffset -1 $licJson = [ordered]@{ licenseKey = $key tier = 'PRO' email = 'test@contoso.com' licenseId = 'test1234' expiresAt = [datetime]::UtcNow.AddDays(-1).ToString('o') issuedAt = [datetime]::UtcNow.AddDays(-366).ToString('o') registeredAt = [datetime]::UtcNow.AddDays(-366).ToString('o') } | ConvertTo-Json -Compress Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $true } Mock Get-Content -ParameterFilter { $Path -like '*SPClean*license.lic' } { $licJson } Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } $result = Get-SPCLicenseInfo $result.Status | Should -Be 'Expired' } } Context 'TypeName' { It 'returns SPC.LicenseInfo TypeName' { Mock Test-Path -ParameterFilter { $Path -like '*SPClean*license.lic' } { $false } $result = Get-SPCLicenseInfo $result.PSObject.TypeNames | Should -Contain 'SPC.LicenseInfo' } } } # ════════════════════════════════════════════════════════════════════════════ Describe 'Assert-SPCProLicense' { BeforeEach { $script:SPCLicenseCache = $null } Context 'Active PRO license' { It 'does not throw with Status=Active Tier=PRO' { Mock Get-SPCLicenseInfo { $r = [PSCustomObject]@{ Status = 'Active'; Tier = 'PRO' } $r } { Assert-SPCProLicense -Feature 'TestFeature' } | Should -Not -Throw } } Context 'Active CONSULTANT license' { It 'does not throw with Status=Active Tier=CONSULTANT' { Mock Get-SPCLicenseInfo { $r = [PSCustomObject]@{ Status = 'Active'; Tier = 'CONSULTANT' } $r } { Assert-SPCProLicense -Feature 'TestFeature' } | Should -Not -Throw } } Context 'No license' { It 'AC-LIC-07: throws ERR-LIC-003 when unlicensed' { Mock Get-SPCLicenseInfo { $r = [PSCustomObject]@{ Status = 'Unlicensed'; Tier = 'FREE' } $r } { Assert-SPCProLicense -Feature 'HTMLReport' } | Should -Throw '*ERR-LIC-003*' } } Context 'Expired license' { It 'throws ERR-LIC-003 when expired' { Mock Get-SPCLicenseInfo { $r = [PSCustomObject]@{ Status = 'Expired'; Tier = 'PRO' } $r } { Assert-SPCProLicense -Feature 'ScheduledScan' } | Should -Throw '*ERR-LIC-003*' } } } # ════════════════════════════════════════════════════════════════════════════ Describe 'Assert-SPCConsultantLicense' { BeforeEach { $script:SPCLicenseCache = $null } Context 'Active CONSULTANT license' { It 'does not throw with Status=Active Tier=CONSULTANT' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Active'; Tier = 'CONSULTANT' } } { Assert-SPCConsultantLicense -Feature 'WhiteLabelReport' } | Should -Not -Throw } } Context 'PRO license is insufficient' { It 'throws ERR-LIC-004 when Tier=PRO (not Consultant)' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Active'; Tier = 'PRO' } } { Assert-SPCConsultantLicense -Feature 'WhiteLabelReport' } | Should -Throw '*ERR-LIC-004*' } } Context 'No license' { It 'throws ERR-LIC-004 when unlicensed' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Unlicensed'; Tier = 'FREE' } } { Assert-SPCConsultantLicense -Feature 'WhiteLabelReport' } | Should -Throw '*ERR-LIC-004*' } } } # ════════════════════════════════════════════════════════════════════════════ Describe 'Register-SPCLicense' { BeforeEach { $script:SPCLicenseCache = $null } Context 'Valid key registration' { It 'AC-LIC-02: returns SPC.LicenseInfo with Status=Active' { $key = New-TestLicenseKey -Tier 'PRO' Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } Mock Get-SPCLicensePathInternal { return (Join-Path $TestDrive 'license.lic') } Mock Test-Path -ParameterFilter { $Path -like (Join-Path $TestDrive '*') } { $false } $result = Register-SPCLicense -LicenseKey $key -Force $result.Status | Should -Be 'Active' $result.Tier | Should -Be 'PRO' } It 'AC-LIC-02: writes license.lic to disk' { $key = New-TestLicenseKey -Tier 'PRO' $licPath = Join-Path $TestDrive 'license.lic' Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } Mock Get-SPCLicensePathInternal { return $licPath } Register-SPCLicense -LicenseKey $key -Force | Out-Null Test-Path $licPath | Should -BeTrue } It 'AC-LIC-02: license.lic contains valid JSON' { $key = New-TestLicenseKey -Tier 'PRO' $licPath = Join-Path $TestDrive 'license.lic' Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } Mock Get-SPCLicensePathInternal { return $licPath } Register-SPCLicense -LicenseKey $key -Force | Out-Null $json = Get-Content $licPath -Raw { $json | ConvertFrom-Json } | Should -Not -Throw } It 'clears SPCLicenseCache after registration' { $key = New-TestLicenseKey -Tier 'PRO' $script:SPCLicenseCache = [PSCustomObject]@{ Status = 'Active' } Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } Mock Get-SPCLicensePathInternal { return (Join-Path $TestDrive 'lic2.lic') } Register-SPCLicense -LicenseKey $key -Force | Out-Null $script:SPCLicenseCache | Should -BeNullOrEmpty } } Context 'AC-LIC-04: Invalid key' { It 'AC-LIC-04: throws ERR-LIC-001 for tampered key' { $key = New-TestLicenseKey -Tier 'PRO' $tampered = $key.Substring(0, $key.Length - 3) + 'AAA' Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } { Register-SPCLicense -LicenseKey $tampered -Force } | Should -Throw '*ERR-LIC-001*' } It 'AC-LIC-06: throws ERR-LIC-001 for malformed string' { Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } { Register-SPCLicense -LicenseKey 'NOTAKEY' -Force } | Should -Throw '*ERR-LIC-001*' } It 'AC-LIC-05: throws ERR-LIC-001 for expired key' { $key = New-TestLicenseKey -ExpiryOffset -1 Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } { Register-SPCLicense -LicenseKey $key -Force } | Should -Throw '*ERR-LIC-001*' } } Context 'AC-LIC-14: License key not in output streams' { It 'AC-LIC-14: license key string does not appear in verbose stream' { $key = New-TestLicenseKey -Tier 'PRO' $licPath = Join-Path $TestDrive 'lic_stream_test.lic' Mock Get-SPCSecretKeyBytesInternal { return [byte[]]@(1..32) } Mock Get-SPCLicensePathInternal { return $licPath } $verboseOut = & { Register-SPCLicense -LicenseKey $key -Force -Verbose 4>&1 } 2>&1 $verboseOut | ForEach-Object { [string]$_ } | Should -Not -Match 'SPCLEAN-(PRO|CONSULTANT)-' } } } # ════════════════════════════════════════════════════════════════════════════ Describe 'Feature Gate Integration' { BeforeEach { $script:SPCLicenseCache = $null } Context 'AC-LIC-07: Export-SPCReport HTML gate' { BeforeAll { . (Join-Path $PSScriptRoot '../../Private/Test-SPCConnection.ps1') . (Join-Path $PSScriptRoot '../../Public/Report/Export-SPCReport.ps1') $script:SPCContext = [PSCustomObject]@{ TenantName = 'test'; AuthMethod = 'Interactive' ConnectedAt = [datetime]::UtcNow; PnPContext = $null GraphAccessToken = 'fake'; _ClientId = $null _CertificatePath = $null; _CertificatePassword = $null; _ClientSecret = $null } $script:FakeLicOrphan = [PSCustomObject][ordered]@{ SiteUrl = 'https://test.sharepoint.com'; SiteTitle = 'Test'; UserId = 1 LoginName = 'i:0#.f|membership|test@test.com'; DisplayName = 'Test'; Email = 'test@test.com' UPN = 'test@test.com'; OrphanType = 'Deleted'; RiskLevel = 'HIGH' HasDirectPermissions = $false; GroupMemberships = @(); LastActivityDate = $null DetectedAt = [datetime]::UtcNow } $script:FakeLicOrphan.PSObject.TypeNames.Insert(0, 'SPC.OrphanedUser') } It 'AC-LIC-07: throws ERR-LIC-003 for HTML without license' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Unlicensed'; Tier = 'FREE' } } { @($script:FakeLicOrphan) | Export-SPCReport -Format HTML -OutputPath (Join-Path $TestDrive 'test.html') } | Should -Throw '*ERR-LIC-003*' } It 'AC-LIC-09: CSV export works without license' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Unlicensed'; Tier = 'FREE' } } $path = Join-Path $TestDrive 'test.csv' { @($script:FakeLicOrphan) | Export-SPCReport -Format CSV -OutputPath $path } | Should -Not -Throw Test-Path $path | Should -BeTrue } } Context 'AC-LIC-10 / AC-LIC-11: Remove-SPCOrphanedUser snapshot gate' { BeforeAll { . (Join-Path $PSScriptRoot '../../Private/Test-SPCConnection.ps1') . (Join-Path $PSScriptRoot '../../Private/Save-SPCPermissionSnapshot.ps1') . (Join-Path $PSScriptRoot '../../Public/Remediate/Remove-SPCOrphanedUser.ps1') $script:SPCContext = [PSCustomObject]@{ TenantName = 'test'; AuthMethod = 'Interactive' ConnectedAt = [datetime]::UtcNow; PnPContext = $null GraphAccessToken = 'fake'; _ClientId = $null _CertificatePath = $null; _CertificatePassword = $null; _ClientSecret = $null } $script:FakeRemoveOrphan = [PSCustomObject][ordered]@{ SiteUrl = 'https://test.sharepoint.com/sites/HR'; SiteTitle = 'HR' UserId = 1; LoginName = 'i:0#.f|membership|jdoe@test.com' DisplayName = 'Jane Doe'; Email = 'jdoe@test.com'; UPN = 'jdoe@test.com' OrphanType = 'Deleted'; RiskLevel = 'HIGH'; HasDirectPermissions = $false GroupMemberships = @(); LastActivityDate = $null; DetectedAt = [datetime]::UtcNow } $script:FakeRemoveOrphan.PSObject.TypeNames.Insert(0, 'SPC.OrphanedUser') } It 'AC-LIC-10: WhatIf + CreateSnapshot does NOT throw without license' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Unlicensed'; Tier = 'FREE' } } { @($script:FakeRemoveOrphan) | Remove-SPCOrphanedUser -CreateSnapshot -WhatIf } | Should -Not -Throw } It 'AC-LIC-11: CreateSnapshot without WhatIf throws ERR-LIC-003 without license' { Mock Get-SPCLicenseInfo { [PSCustomObject]@{ Status = 'Unlicensed'; Tier = 'FREE' } } { @($script:FakeRemoveOrphan) | Remove-SPCOrphanedUser -CreateSnapshot -Force } | Should -Throw '*ERR-LIC-003*' } } } |