Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 33 additions & 36 deletions Public/Export-AzRetirementReport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,37 +28,43 @@ Exports retirement recommendations to CSV, JSON, or HTML
return
}

# Transform data to use Description in Solution column for Az.Advisor mode
# (when Problem and Solution are the same, Description usually has better info)
# This transformation is used for CSV, JSON, and HTML formats
$transformedRecs = $allRecs | ForEach-Object {
$rec = $_ | Select-Object -Property * -ExcludeProperty Solution
# Use Description if Problem == Solution and Description exists
# This indicates Az.Advisor mode where generic text is duplicated
$solutionValue = if ($_.Problem -eq $_.Solution -and $_.Description) {
$_.Description
} else {
$_.Solution
}
$rec | Add-Member -MemberType NoteProperty -Name "Solution" -Value $solutionValue -Force
$rec
}

switch ($Format) {
"CSV" {
# Transform data to use Description in Solution column for Az.Advisor mode
# (when Problem and Solution are the same, Description usually has better info)
$transformedRecs = $allRecs | ForEach-Object {
$rec = $_ | Select-Object -Property * -ExcludeProperty Solution
# Use Description if Problem == Solution and Description exists
# This indicates Az.Advisor mode where generic text is duplicated
$solutionValue = if ($_.Problem -eq $_.Solution -and $_.Description) {
$_.Description
} else {
$_.Solution
# Sanitize potential formula injections for CSV consumers (e.g., Excel)
$safeRecs = $transformedRecs | ForEach-Object {
# Clone the object so JSON/HTML exports remain unaffected
$safe = $_ | Select-Object *
foreach ($prop in $safe.PSObject.Properties) {
$value = $prop.Value
if ($value -is [string] -and $value.Length -gt 0) {
$firstChar = $value[0]
if ($firstChar -in '=','+','-','@') {
# Prefix with a single quote so spreadsheet apps treat it as text
$prop.Value = "'" + $value
}
}
}
$rec | Add-Member -MemberType NoteProperty -Name "Solution" -Value $solutionValue -Force
$rec
$safe
}
$transformedRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
$safeRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
}
"JSON" {
# Transform data to use Description in Solution column for Az.Advisor mode
$transformedRecs = $allRecs | ForEach-Object {
$rec = $_ | Select-Object -Property * -ExcludeProperty Solution
# Use Description if Problem == Solution and Description exists
$solutionValue = if ($_.Problem -eq $_.Solution -and $_.Description) {
$_.Description
} else {
$_.Solution
}
$rec | Add-Member -MemberType NoteProperty -Name "Solution" -Value $solutionValue -Force
$rec
}
$transformedRecs | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Encoding utf8
}
"HTML" {
Expand Down Expand Up @@ -215,23 +221,14 @@ Exports retirement recommendations to CSV, JSON, or HTML
"@

# Add table rows - collect in array for better performance
$tableRows = foreach ($rec in $allRecs) {
$tableRows = foreach ($rec in $transformedRecs) {
# HTML encode all user-provided data to prevent XSS
$encodedResourceName = ConvertTo-HtmlEncoded $rec.ResourceName
$encodedResourceType = ConvertTo-HtmlEncoded $rec.ResourceType
$encodedResourceGroup = ConvertTo-HtmlEncoded $rec.ResourceGroup
$encodedImpact = ConvertTo-HtmlEncoded $rec.Impact
$encodedProblem = ConvertTo-HtmlEncoded $rec.Problem

# Use Description if Problem == Solution (Az.Advisor mode) and Description exists
# This indicates generic duplicated text; use the more informative Description
# For API mode where Problem != Solution, keep original Solution
$solutionText = if ($rec.Problem -eq $rec.Solution -and $rec.Description) {
$rec.Description
} else {
$rec.Solution
}
$encodedSolution = ConvertTo-HtmlEncoded $solutionText
$encodedSolution = ConvertTo-HtmlEncoded $rec.Solution
$encodedSubscriptionId = ConvertTo-HtmlEncoded $rec.SubscriptionId

# Validate and sanitize CSS class name to prevent CSS injection
Expand Down
156 changes: 156 additions & 0 deletions Tests/AzRetirementMonitor.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,162 @@ Describe "Export-AzRetirementReport" {
}
}

Describe "Export-AzRetirementReport Transformation Logic" {
BeforeAll {
# Create a temporary directory for test outputs
$script:TestOutputDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzRetirementMonitorTests_$([guid]::NewGuid())"
New-Item -Path $script:TestOutputDir -ItemType Directory -Force | Out-Null
}

AfterAll {
# Clean up test output directory
if (Test-Path $script:TestOutputDir) {
Remove-Item -Path $script:TestOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
}

Context "When Problem equals Solution (Az.Advisor mode)" {
It "Should replace Solution with Description in CSV when Description exists" {
$testRec = [PSCustomObject]@{
ResourceName = "TestVM"
ResourceType = "Microsoft.Compute/virtualMachines"
Problem = "Generic retirement notice"
Solution = "Generic retirement notice"
Description = "Detailed upgrade instructions for this VM"
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "High"
}

$outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.csv"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false

$result = Import-Csv -Path $outputPath
$result.Solution | Should -Be "Detailed upgrade instructions for this VM"
}

It "Should replace Solution with Description in JSON when Description exists" {
$testRec = [PSCustomObject]@{
ResourceName = "TestStorage"
ResourceType = "Microsoft.Storage/storageAccounts"
Problem = "Service retiring"
Solution = "Service retiring"
Description = "Migrate to new storage account type"
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "Medium"
}

$outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.json"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format JSON -Confirm:$false

$result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json
$result.Solution | Should -Be "Migrate to new storage account type"
}

It "Should replace Solution with Description in HTML when Description exists" {
$testRec = [PSCustomObject]@{
ResourceName = "TestDB"
ResourceType = "Microsoft.Sql/servers/databases"
Problem = "Upgrade required"
Solution = "Upgrade required"
Description = "Move to SQL Database v2"
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "Low"
ResourceLink = "https://portal.azure.com/resource"
LearnMoreLink = "https://learn.microsoft.com/azure"
}

$outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.html"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format HTML -Confirm:$false

$htmlContent = Get-Content -Path $outputPath -Raw
$htmlContent | Should -Match "Move to SQL Database v2"
}
}

Context "When Problem differs from Solution (API mode)" {
It "Should keep original Solution in CSV when Problem differs" {
$testRec = [PSCustomObject]@{
ResourceName = "TestApp"
ResourceType = "Microsoft.Web/sites"
Problem = "API version deprecated"
Solution = "Update to API version 2023-01-01"
Description = "Additional context"
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "High"
}

$outputPath = Join-Path $script:TestOutputDir "test-api-mode.csv"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false

$result = Import-Csv -Path $outputPath
$result.Solution | Should -Be "Update to API version 2023-01-01"
}

It "Should keep original Solution in JSON when Problem differs" {
$testRec = [PSCustomObject]@{
ResourceName = "TestFunction"
ResourceType = "Microsoft.Web/sites/functions"
Problem = "Runtime version retiring"
Solution = "Upgrade to .NET 8"
Description = "Migration guide available"
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "Medium"
}

$outputPath = Join-Path $script:TestOutputDir "test-api-mode.json"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format JSON -Confirm:$false

$result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json
$result.Solution | Should -Be "Upgrade to .NET 8"
}
}

Context "Edge cases" {
It "Should keep Solution when Description is null even if Problem equals Solution" {
$testRec = [PSCustomObject]@{
ResourceName = "TestResource"
ResourceType = "Microsoft.Test/resources"
Problem = "Action required"
Solution = "Action required"
Description = $null
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "Low"
}

$outputPath = Join-Path $script:TestOutputDir "test-null-description.csv"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false

$result = Import-Csv -Path $outputPath
$result.Solution | Should -Be "Action required"
}

It "Should keep Solution when Description is empty string even if Problem equals Solution" {
$testRec = [PSCustomObject]@{
ResourceName = "TestResource2"
ResourceType = "Microsoft.Test/resources"
Problem = "Update needed"
Solution = "Update needed"
Description = ""
ResourceGroup = "test-rg"
SubscriptionId = "test-sub-id"
Impact = "Low"
}

$outputPath = Join-Path $script:TestOutputDir "test-empty-description.csv"
$testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false

$result = Import-Csv -Path $outputPath
$result.Solution | Should -Be "Update needed"
}
}
}

Describe "Token Expiration Validation" {
BeforeAll {
# Helper function to create a test JWT token with specified expiration
Expand Down