diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 index be9f84fb75..1fe72a7775 100644 --- a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.Action.ps1 @@ -9,6 +9,8 @@ [string] $token ) +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath "DownloadProjectDependencies.psm1" -Resolve) -DisableNameChecking + function DownloadDependenciesFromProbingPaths { param( $baseFolder, @@ -123,6 +125,12 @@ Write-Host "::group::Downloading project dependencies from probing paths" $downloadedDependencies += DownloadDependenciesFromProbingPaths -baseFolder $baseFolder -project $project -destinationPath $destinationPath -token $token Write-Host "::endgroup::" +Write-Host "::group::Downloading dependencies from settings (installApps and installTestApps)" +Push-Location -Path (Join-Path $baseFolder $project) #Change to the project folder because installApps paths are relative to the project folder +$settingsDependencies = Get-DependenciesFromInstallApps -DestinationPath $destinationPath +Pop-Location +Write-Host "::endgroup::" + $downloadedApps = @() $downloadedTestApps = @() @@ -137,6 +145,10 @@ $downloadedDependencies | ForEach-Object { } } +# Add dependencies from settings +$downloadedApps += $settingsDependencies.Apps +$downloadedTestApps += $settingsDependencies.TestApps + OutputMessageAndArray -message "Downloaded dependencies (Apps)" -arrayOfStrings $downloadedApps OutputMessageAndArray -message "Downloaded dependencies (Test Apps)" -arrayOfStrings $downloadedTestApps diff --git a/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 new file mode 100644 index 0000000000..c43be0d36b --- /dev/null +++ b/Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1 @@ -0,0 +1,299 @@ +Import-Module -Name (Join-Path $PSScriptRoot '../Github-Helper.psm1') +. (Join-Path -Path $PSScriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) + +<# + .SYNOPSIS + Tests if a file is a ZIP archive by checking for the "PK" magic bytes. + .PARAMETER Path + The path to the file to test. + .OUTPUTS + $true if the file is a ZIP archive, $false otherwise. +#> +function Test-IsZipFile { + Param( + [string] $Path + ) + $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant() + if ($extension -eq '.zip') { + return $true + } + # Check for ZIP magic bytes "PK" (0x50 0x4B) + # This handles the case where the file does not have a .zip extension but is still a ZIP archive (like .nupkg) + if ($PSVersionTable.PSVersion.Major -ge 6) { + $bytes = Get-Content -Path $Path -AsByteStream -TotalCount 2 -ErrorAction SilentlyContinue + } else { + $bytes = Get-Content -Path $Path -Encoding Byte -TotalCount 2 -ErrorAction SilentlyContinue + } + if ($bytes -and $bytes.Count -eq 2) { + return ([char]$bytes[0] -eq 'P') -and ([char]$bytes[1] -eq 'K') + } + return $false +} + +<# + .SYNOPSIS + Extracts .app files from a ZIP archive to the destination path. + .PARAMETER ZipFile + The path to the ZIP file. + .PARAMETER DestinationPath + The path where .app files should be extracted to. + .OUTPUTS + An array of paths to the extracted .app files. +#> +function Expand-ZipFileToAppFiles { + Param( + [string] $ZipFile, + [string] $DestinationPath + ) + $fileName = [System.IO.Path]::GetFileName($ZipFile) + OutputDebug -message "Expanding zip file to extract .app files: $ZipFile" + + # If file doesn't have .zip extension, copy to temp with .zip extension for Expand-Archive + $zipToExtract = $ZipFile + $tempZipCreated = $false + if ([System.IO.Path]::GetExtension($ZipFile).ToLowerInvariant() -ne '.zip') { + $zipToExtract = Join-Path (GetTemporaryPath) "$([System.IO.Path]::GetFileName($ZipFile)).zip" + Copy-Item -Path $ZipFile -Destination $zipToExtract + $tempZipCreated = $true + } + + try { + # Extract to runner temp folder + $extractPath = Join-Path (GetTemporaryPath) ([System.IO.Path]::GetFileNameWithoutExtension($fileName)) + Expand-Archive -Path $zipToExtract -DestinationPath $extractPath -Force + + # Find all files in the extracted folder and process them + $appFiles = @() + foreach ($file in (Get-ChildItem -Path $extractPath -Recurse -File)) { + $extension = [System.IO.Path]::GetExtension($file.FullName).ToLowerInvariant() + + if ($extension -eq '.app') { + $destFile = Join-Path $DestinationPath $file.Name + Copy-Item -Path $file.FullName -Destination $destFile -Force + $appFiles += $destFile + } + elseif (Test-IsZipFile -Path $file.FullName) { + # Recursively extract nested ZIP files + $appFiles += Expand-ZipFileToAppFiles -ZipFile $file.FullName -DestinationPath $DestinationPath + } + } + + # Clean up the extracted folder + Remove-Item -Path $extractPath -Recurse -Force + + if ($appFiles.Count -eq 0) { + OutputWarning -message "No .app files found in zip archive: $fileName" + } else { + OutputDebug -message "Found $($appFiles.Count) .app file(s) in zip archive" + } + return $appFiles + } + finally { + if ($tempZipCreated) { + Remove-Item -Path $zipToExtract -Force -ErrorAction SilentlyContinue + } + } +} + +<# + .SYNOPSIS + Resolves a local path to an array of .app file paths. + .DESCRIPTION + Handles local files and folders: + - If path is an .app file: returns it + - If path is a folder: recursively finds all .app files + - If path contains wildcards: resolves them to matching files + - If path is a ZIP file (by extension or magic bytes): extracts and returns .app files + .PARAMETER Path + The local file or folder path. + .PARAMETER DestinationPath + The path where extracted .app files should be placed (for ZIP files). + .OUTPUTS + An array of paths to .app files. +#> +function Get-AppFilesFromLocalPath { + Param( + [string] $Path, + [string] $DestinationPath + ) + + # Ensure the destination directory exists + if (-not (Test-Path -Path $DestinationPath)) { + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + } + + # Get all matching items (works for folders, wildcards, and single files) + $matchedItems = @(Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue) + + if ($matchedItems.Count -eq 0) { + OutputWarning -message "No files found at local path: $Path" + return @() + } + + # Process each matched file + $appFiles = @() + foreach ($item in $matchedItems) { + $extension = [System.IO.Path]::GetExtension($item.FullName).ToLowerInvariant() + + if ($extension -eq '.app') { + $destFile = Join-Path $DestinationPath $item.Name + Copy-Item -Path $item.FullName -Destination $destFile -Force + $appFiles += $destFile + } elseif (Test-IsZipFile -Path $item.FullName) { + $appFiles += Expand-ZipFileToAppFiles -ZipFile $item.FullName -DestinationPath $DestinationPath + } else { + OutputWarning -message "Unknown file type for local path: $($item.FullName). Skipping." + } + } + return $appFiles +} + +<# + .SYNOPSIS + Downloads a file from a URL to a specified download path. + .DESCRIPTION + Downloads a file from a URL to a specified download path. + It handles URL decoding and sanitizes the file name. + If the downloaded file is a zip file, it extracts the .app files from it. + .PARAMETER Url + The URL of the file to download. + .PARAMETER CleanUrl + The original URL for error reporting. + .PARAMETER DownloadPath + The path where the file should be downloaded. + .OUTPUTS + An array of paths to the downloaded/extracted .app files. +#> +function Get-AppFilesFromUrl { + Param( + [string] $Url, + [string] $CleanUrl, + [string] $DownloadPath + ) + + # Ensure the download directory exists + if (-not (Test-Path -Path $DownloadPath)) { + New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null + } + + # Get the file name from the URL + $urlWithoutQuery = $Url.Split('?')[0].TrimEnd('/') + $rawFileName = [System.IO.Path]::GetFileName($urlWithoutQuery) + $decodedFileName = [Uri]::UnescapeDataString($rawFileName) + $decodedFileName = [System.IO.Path]::GetFileName($decodedFileName) + + # Sanitize file name by removing invalid characters + $sanitizedFileName = $decodedFileName.Split([System.IO.Path]::getInvalidFileNameChars()) -join "" + $sanitizedFileName = $sanitizedFileName.Trim() + + if ([string]::IsNullOrWhiteSpace($sanitizedFileName)) { + # Assume the file is an .app file if no valid name could be determined + $sanitizedFileName = "$([Guid]::NewGuid().ToString()).app" + } + + # Get the final file path + $downloadedFile = Join-Path $DownloadPath $sanitizedFileName + if (Test-Path -LiteralPath $downloadedFile) { + OutputWarning -message "Overwriting existing file '$sanitizedFileName'. Multiple dependencies may resolve to the same filename." + } + + # Download with retry logic + try { + Invoke-CommandWithRetry -ScriptBlock { + Invoke-WebRequest -Method GET -UseBasicParsing -Uri $Url -OutFile $downloadedFile | Out-Null + } -RetryCount 3 -FirstDelay 5 -MaxWaitBetweenRetries 10 + OutputDebug -message "Downloaded file to path: $downloadedFile" + } catch { + throw "Failed to download file from inaccessible URL: $CleanUrl. Error was: $($_.Exception.Message)" + } + + # Check if the downloaded file is a zip file (by extension or magic bytes) + if (Test-IsZipFile -Path $downloadedFile) { + $appFiles = Expand-ZipFileToAppFiles -ZipFile $downloadedFile -DestinationPath $DownloadPath + Remove-Item -Path $downloadedFile -Force + return $appFiles + } + + return @($downloadedFile) +} + +<# + .SYNOPSIS + Downloads dependencies from URLs specified in installApps and installTestApps settings. + .DESCRIPTION + Reads the installApps and installTestApps arrays from the repository settings. + For each entry that is a URL (starts with http:// or https://): + - Resolves any secret placeholders in the format ${{ secretName }} by looking up the secret value + - Downloads the app file to the specified destination path + For entries that are local paths: + - Resolves folders to their contained .app files + - Extracts .app files from ZIP archives + .PARAMETER DestinationPath + The path where the app files should be downloaded. + .OUTPUTS + A hashtable with Apps and TestApps arrays containing the resolved local file paths. +#> +function Get-DependenciesFromInstallApps { + Param( + [string] $DestinationPath + ) + + $settings = $env:Settings | ConvertFrom-Json | ConvertTo-HashTable + + # ENV:Secrets is not set when running Pull_Request trigger + if ($env:Secrets) { + $secrets = $env:Secrets | ConvertFrom-Json | ConvertTo-HashTable + } + else { + $secrets = @{} + } + + # Initialize the install hashtable + $install = @{ + "Apps" = @($settings.installApps) + "TestApps" = @($settings.installTestApps) + } + + # Check if the installApps and installTestApps settings are empty + if (($settings.installApps.Count -eq 0) -and ($settings.installTestApps.Count -eq 0)) { + Write-Host "No installApps or installTestApps settings found." + return $install + } + + # Replace secret names in install.apps and install.testApps and download files from URLs + foreach($list in @('Apps','TestApps')) { + + $updatedListOfFiles = @() + foreach($appFile in $install."$list") { + Write-Host "Processing install$($list) entry: $appFile" + + # If the app file is not a URL, resolve local path. + if ($appFile -notlike 'http*://*') { + $updatedListOfFiles += Get-AppFilesFromLocalPath -Path $appFile -DestinationPath $DestinationPath + } else { + # Else, check for secrets in the URL and replace them + $appFileUrl = $appFile + $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' + if ($appFile -match $pattern) { + $secretName = $matches[2] + if (-not $secrets.ContainsKey($secretName)) { + throw "Setting: install$($list) references unknown secret '$secretName' in URL: $appFile" + } + $appFileUrl = $appFileUrl.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$secretName"))) + } + + # Download the file (may return multiple .app files if it's a zip) + $appFiles = Get-AppFilesFromUrl -Url $appFileUrl -CleanUrl $appFile -DownloadPath $DestinationPath + + $updatedListOfFiles += $appFiles + } + } + + # Update the install hashtable with the resolved file paths + $install."$list" = $updatedListOfFiles + } + + return $install +} + +Export-ModuleMember -Function Get-AppFilesFromUrl, Get-AppFilesFromLocalPath, Get-DependenciesFromInstallApps diff --git a/Actions/RunPipeline/RunPipeline.ps1 b/Actions/RunPipeline/RunPipeline.ps1 index fcde6d99f7..12b4b2b491 100644 --- a/Actions/RunPipeline/RunPipeline.ps1 +++ b/Actions/RunPipeline/RunPipeline.ps1 @@ -193,13 +193,13 @@ try { } $install = @{ - "Apps" = $settings.installApps - "TestApps" = $settings.installTestApps + "Apps" = @() + "TestApps" = @() } if ($installAppsJson -and (Test-Path $installAppsJson)) { try { - $install.Apps += @(Get-Content -Path $installAppsJson -Raw | ConvertFrom-Json) + $install.Apps = Get-Content -Path $installAppsJson | ConvertFrom-Json } catch { throw "Failed to parse JSON file at path '$installAppsJson'. Error: $($_.Exception.Message)" @@ -208,7 +208,7 @@ try { if ($installTestAppsJson -and (Test-Path $installTestAppsJson)) { try { - $install.TestApps += @(Get-Content -Path $installTestAppsJson -Raw | ConvertFrom-Json) + $install.TestApps = Get-Content -Path $installTestAppsJson | ConvertFrom-Json } catch { throw "Failed to parse JSON file at path '$installTestAppsJson'. Error: $($_.Exception.Message)" @@ -220,30 +220,6 @@ try { $install.TestApps = $install.TestApps | ForEach-Object { $_.TrimStart("(").TrimEnd(")") } } - # Replace secret names in install.apps and install.testApps - foreach($list in @('Apps','TestApps')) { - $install."$list" = @($install."$list" | ForEach-Object { - $pattern = '.*(\$\{\{\s*([^}]+?)\s*\}\}).*' - $url = $_ - if ($url -match $pattern) { - $finalUrl = $url.Replace($matches[1],[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($secrets."$($matches[2])"))) - } - else { - $finalUrl = $url - } - # Check validity of URL - if ($finalUrl -like 'http*://*') { - try { - Invoke-WebRequest -Method Head -UseBasicParsing -Uri $finalUrl | Out-Null - } - catch { - throw "Setting: install$($list) contains an inaccessible URL: $($url). Error was: $($_.Exception.Message)" - } - } - return $finalUrl - }) - } - # Analyze app.json version dependencies before launching pipeline # Analyze InstallApps and InstallTestApps before launching pipeline diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 246266787a..224eeabd4f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -18,6 +18,14 @@ Previously, when running the "Publish To Environment" workflow with an environme Now, the workflow will fail with a clear error message if the specified environment doesn't exist. If you intentionally want to deploy to a new environment that hasn't been configured yet, you can check the **Create environment if it does not exist** checkbox when running the workflow. +### Improving error detection and build reliability when downloading project dependencies + +The `DownloadProjectDependencies` action now downloads app files from URLs specified in the `installApps` and `installTestApps` settings upfront, rather than validating URLs at build time. This change provides: + +- Earlier detection of inaccessible or misconfigured URLs +- Clearer error messages when secrets are missing or URLs are invalid +- Warnings for potential issues like duplicate filenames + ### Set default values for workflow inputs The `workflowDefaultInputs` setting now also applies to `workflow_call` inputs when an input with the same name exists for `workflow_dispatch`. diff --git a/Tests/DownloadProjectDependencies.Test.ps1 b/Tests/DownloadProjectDependencies.Test.ps1 new file mode 100644 index 0000000000..6a77f375e8 --- /dev/null +++ b/Tests/DownloadProjectDependencies.Test.ps1 @@ -0,0 +1,428 @@ +Get-Module TestActionsHelper | Remove-Module -Force +Import-Module (Join-Path $PSScriptRoot 'TestActionsHelper.psm1') +$errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 + +# Import AL-Go-Helper first (needed for helper functions) +. (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) + +# Import the module +Import-Module (Join-Path $PSScriptRoot "../Actions/DownloadProjectDependencies/DownloadProjectDependencies.psm1" -Resolve) -Force + +Describe "DownloadProjectDependencies - Get-AppFilesFromUrl Tests" { + BeforeEach { + # Create a temp download folder + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'downloadPath', Justification = 'False positive.')] + $downloadPath = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Set up RUNNER_TEMP for zip extraction + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalRunnerTemp', Justification = 'False positive.')] + $originalRunnerTemp = $env:RUNNER_TEMP + $env:RUNNER_TEMP = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Create a test .app file to use as mock response + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'mockAppFile', Justification = 'False positive.')] + $mockAppFile = Join-Path $env:RUNNER_TEMP "MockApp.app" + [System.IO.File]::WriteAllBytes($mockAppFile, [byte[]](1, 2, 3, 4, 5)) + } + + AfterEach { + # Clean up + if (Test-Path $downloadPath) { + Remove-Item -Path $downloadPath -Recurse -Force + } + if ($env:RUNNER_TEMP -and (Test-Path $env:RUNNER_TEMP)) { + Remove-Item -Path $env:RUNNER_TEMP -Recurse -Force + } + $env:RUNNER_TEMP = $originalRunnerTemp + } + + It 'Downloads a single .app file from URL' { + # Mock Invoke-WebRequest at module level - this works because Invoke-CommandWithRetry + # calls Invoke-WebRequest with a scriptblock that runs in the module scope + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app" -CleanUrl "https://example.com/downloads/TestApp.app" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -BeLike "*TestApp.app" + Test-Path @($result)[0] | Should -BeTrue + } + + It 'Extracts .app files from a zip archive' { + # Create test .app files in a zip + $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSource" + New-Item -ItemType Directory -Path $zipSourcePath | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "App2.app"), [byte[]](4, 5, 6)) + $zipPath = Join-Path $env:RUNNER_TEMP "TestApps.zip" + Compress-Archive -Path "$zipSourcePath\*" -DestinationPath $zipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $zipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApps.zip" -CleanUrl "https://example.com/downloads/TestApps.zip" -DownloadPath $downloadPath + + $result | Should -HaveCount 2 + $result | Should -Contain (Join-Path $downloadPath "App1.app") + $result | Should -Contain (Join-Path $downloadPath "App2.app") + Test-Path (Join-Path $downloadPath "App1.app") | Should -BeTrue + Test-Path (Join-Path $downloadPath "App2.app") | Should -BeTrue + # Zip file should be removed + Test-Path (Join-Path $downloadPath "TestApps.zip") | Should -BeFalse + } + + It 'Extracts .app files from nested folders in zip archive' { + # Create test .app files in nested structure + $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSourceNested" + New-Item -ItemType Directory -Path "$zipSourcePath\folder1\subfolder" -Force | Out-Null + New-Item -ItemType Directory -Path "$zipSourcePath\folder2" -Force | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "folder1\subfolder\NestedApp.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $zipSourcePath "folder2\AnotherApp.app"), [byte[]](4, 5, 6)) + $zipPath = Join-Path $env:RUNNER_TEMP "NestedApps.zip" + Compress-Archive -Path "$zipSourcePath\*" -DestinationPath $zipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $zipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NestedApps.zip" -CleanUrl "https://example.com/downloads/NestedApps.zip" -DownloadPath $downloadPath + + $result | Should -HaveCount 2 + $result | Should -Contain (Join-Path $downloadPath "NestedApp.app") + $result | Should -Contain (Join-Path $downloadPath "AnotherApp.app") + } + + It 'Extracts .app files from nested ZIP inside ZIP' { + # Create inner ZIP with .app file + $innerZipSource = Join-Path $env:RUNNER_TEMP "InnerZipSource" + New-Item -ItemType Directory -Path $innerZipSource -Force | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $innerZipSource "InnerApp.app"), [byte[]](1, 2, 3)) + $innerZipPath = Join-Path $env:RUNNER_TEMP "InnerApps.zip" + Compress-Archive -Path "$innerZipSource\*" -DestinationPath $innerZipPath + + # Create outer ZIP containing the inner ZIP and another .app + $outerZipSource = Join-Path $env:RUNNER_TEMP "OuterZipSource" + New-Item -ItemType Directory -Path $outerZipSource -Force | Out-Null + Copy-Item -Path $innerZipPath -Destination (Join-Path $outerZipSource "InnerApps.zip") + [System.IO.File]::WriteAllBytes((Join-Path $outerZipSource "OuterApp.app"), [byte[]](4, 5, 6)) + $outerZipPath = Join-Path $env:RUNNER_TEMP "OuterApps.zip" + Compress-Archive -Path "$outerZipSource\*" -DestinationPath $outerZipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $outerZipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/OuterApps.zip" -CleanUrl "https://example.com/downloads/OuterApps.zip" -DownloadPath $downloadPath + + $result | Should -HaveCount 2 + $result | Should -Contain (Join-Path $downloadPath "OuterApp.app") + $result | Should -Contain (Join-Path $downloadPath "InnerApp.app") + } + + It 'Returns empty array and warns when zip contains no .app files' { + # Create zip with non-.app files + $zipSourcePath = Join-Path $env:RUNNER_TEMP "ZipSourceNoApps" + New-Item -ItemType Directory -Path $zipSourcePath | Out-Null + Set-Content -Path (Join-Path $zipSourcePath "readme.txt") -Value "No apps here" + $zipPath = Join-Path $env:RUNNER_TEMP "NoApps.zip" + Compress-Archive -Path "$zipSourcePath\*" -DestinationPath $zipPath + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + Copy-Item -Path $zipPath -Destination $OutFile -Force + } -ModuleName DownloadProjectDependencies + + Mock OutputWarning {} -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/NoApps.zip" -CleanUrl "https://example.com/downloads/NoApps.zip" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 0 + Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { + $message -like "*No .app files found in zip archive*" + } + } + + It 'Handles URL with query parameters' { + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) + } -ModuleName DownloadProjectDependencies + + $result = Get-AppFilesFromUrl -Url "https://example.com/downloads/TestApp.app?token=abc123&expires=2025" -CleanUrl "https://example.com/downloads/TestApp.app?token=abc123&expires=2025" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -BeLike "*TestApp.app" + } + + It 'Generates GUID filename when URL path contains only invalid characters' { + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3, 4, 5)) + } -ModuleName DownloadProjectDependencies + + # URL with only spaces/invalid chars as filename (after sanitization becomes empty) + $result = Get-AppFilesFromUrl -Url "https://example.com/%20%20%20" -CleanUrl "https://example.com/%20%20%20" -DownloadPath $downloadPath + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -Match "\.app$" + # Should be a GUID pattern like: 12345678-1234-1234-1234-123456789abc.app + @($result)[0] | Should -Match "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.app$" + Test-Path @($result)[0] | Should -BeTrue + } +} + +Describe "DownloadProjectDependencies - Get-AppFilesFromLocalPath Tests" { + BeforeEach { + # Create a temp folder for test files + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFolder', Justification = 'False positive.')] + $testFolder = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Create destination folder for extracted files + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'destFolder', Justification = 'False positive.')] + $destFolder = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Set up RUNNER_TEMP for zip extraction + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalRunnerTemp', Justification = 'False positive.')] + $originalRunnerTemp = $env:RUNNER_TEMP + $env:RUNNER_TEMP = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + } + + AfterEach { + if (Test-Path $testFolder) { + Remove-Item -Path $testFolder -Recurse -Force + } + if (Test-Path $destFolder) { + Remove-Item -Path $destFolder -Recurse -Force + } + if ($env:RUNNER_TEMP -and (Test-Path $env:RUNNER_TEMP)) { + Remove-Item -Path $env:RUNNER_TEMP -Recurse -Force + } + $env:RUNNER_TEMP = $originalRunnerTemp + } + + It 'Copies single .app file to destination' { + $appFile = Join-Path $testFolder "MyApp.app" + [System.IO.File]::WriteAllBytes($appFile, [byte[]](1, 2, 3)) + + $result = Get-AppFilesFromLocalPath -Path $appFile -DestinationPath $destFolder + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -Be (Join-Path $destFolder "MyApp.app") + Test-Path @($result)[0] | Should -BeTrue + } + + It 'Copies all .app files from folder to destination' { + # Create nested structure + $subFolder = New-Item -ItemType Directory -Path (Join-Path $testFolder "SubFolder") + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $subFolder "App2.app"), [byte[]](4, 5, 6)) + [System.IO.File]::WriteAllBytes((Join-Path $subFolder "NotAnApp.txt"), [byte[]](7, 8, 9)) + + $result = Get-AppFilesFromLocalPath -Path $testFolder -DestinationPath $destFolder + + @($result) | Should -HaveCount 2 + @($result) | Should -Contain (Join-Path $destFolder "App1.app") + @($result) | Should -Contain (Join-Path $destFolder "App2.app") + Test-Path (Join-Path $destFolder "App1.app") | Should -BeTrue + Test-Path (Join-Path $destFolder "App2.app") | Should -BeTrue + Test-Path (Join-Path $destFolder "NotAnApp.txt") | Should -BeFalse + } + + It 'Resolves wildcard patterns' { + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App1.app"), [byte[]](1, 2, 3)) + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "App2.app"), [byte[]](4, 5, 6)) + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "Other.txt"), [byte[]](7, 8, 9)) + + $result = Get-AppFilesFromLocalPath -Path (Join-Path $testFolder "*.app") -DestinationPath $destFolder + + @($result) | Should -HaveCount 2 + } + + It 'Extracts .app files from a .nupkg file (ZIP with different extension)' { + # Create a .nupkg (which is really a ZIP) - create as .zip first, then rename for PS5 compatibility + $nupkgContentFolder = Join-Path $env:RUNNER_TEMP "NupkgContent" + New-Item -ItemType Directory -Path $nupkgContentFolder | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $nupkgContentFolder "PackagedApp.app"), [byte[]](1, 2, 3)) + + $tempZipFile= Join-Path $testFolder "MyPackage.zip" + Compress-Archive -Path (Join-Path $nupkgContentFolder "*") -DestinationPath $tempZipFile + $nupkgFile = Join-Path $testFolder "MyPackage.nupkg" + Move-Item -Path $tempZipFile -Destination $nupkgFile + + $result = Get-AppFilesFromLocalPath -Path $nupkgFile -DestinationPath $destFolder + + @($result) | Should -HaveCount 1 + @($result)[0] | Should -BeLike "*PackagedApp.app" + Test-Path @($result)[0] | Should -BeTrue + } + + It 'Returns empty array for wildcard pattern with no matches' { + $result = Get-AppFilesFromLocalPath -Path (Join-Path $testFolder "*.app") -DestinationPath $destFolder + + @($result) | Should -HaveCount 0 + } + + It 'Warns when no files found at local path' { + Mock OutputWarning {} -ModuleName DownloadProjectDependencies + + # Use a cross-platform path (nested Join-Path for PS5 compatibility) + $nonExistentPath = Join-Path (Join-Path $testFolder "NonExistent") "Path.app" + $result = Get-AppFilesFromLocalPath -Path $nonExistentPath -DestinationPath $destFolder + + @($result) | Should -HaveCount 0 + Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { + $message -like "*No files found at local path*" + } + } + + It 'Warns when encountering unknown file types' { + Mock OutputWarning {} -ModuleName DownloadProjectDependencies + + [System.IO.File]::WriteAllBytes((Join-Path $testFolder "readme.txt"), [byte[]](1, 2, 3)) + + $result = Get-AppFilesFromLocalPath -Path $testFolder -DestinationPath $destFolder + + @($result) | Should -HaveCount 0 + Should -Invoke OutputWarning -ModuleName DownloadProjectDependencies -Times 1 -ParameterFilter { + $message -like "*Unknown file type*" + } + } +} + +Describe "DownloadProjectDependencies - Get-DependenciesFromInstallApps Tests" { + BeforeEach { + # Create a temp download folder + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'downloadPath', Justification = 'False positive.')] + $downloadPath = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Set up RUNNER_TEMP for zip extraction + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalRunnerTemp', Justification = 'False positive.')] + $originalRunnerTemp = $env:RUNNER_TEMP + $env:RUNNER_TEMP = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + + # Store original env vars + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalSettings', Justification = 'False positive.')] + $originalSettings = $env:Settings + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalSecrets', Justification = 'False positive.')] + $originalSecrets = $env:Secrets + } + + AfterEach { + # Clean up + if (Test-Path $downloadPath) { + Remove-Item -Path $downloadPath -Recurse -Force + } + if ($env:RUNNER_TEMP -and (Test-Path $env:RUNNER_TEMP)) { + Remove-Item -Path $env:RUNNER_TEMP -Recurse -Force + } + $env:RUNNER_TEMP = $originalRunnerTemp + $env:Settings = $originalSettings + $env:Secrets = $originalSecrets + } + + It 'Returns empty arrays when no installApps or installTestApps configured' { + $env:Settings = @{ + installApps = @() + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -HaveCount 0 + $result.TestApps | Should -HaveCount 0 + } + + It 'Copies local .app files to destination path' { + # Create temporary test files in a source folder (not the download path) + $sourceFolder = Join-Path $env:RUNNER_TEMP "SourceApps" + New-Item -ItemType Directory -Path $sourceFolder | Out-Null + $testAppFile = Join-Path $sourceFolder "MyApp.app" + [System.IO.File]::WriteAllBytes($testAppFile, [byte[]](1, 2, 3)) + $testTestAppFile = Join-Path $sourceFolder "TestApp.app" + [System.IO.File]::WriteAllBytes($testTestAppFile, [byte[]](1, 2, 3)) + + $env:Settings = @{ + installApps = @($testAppFile) + installTestApps = @($testTestAppFile) + } | ConvertTo-Json -Depth 10 + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -Contain (Join-Path $downloadPath "MyApp.app") + $result.TestApps | Should -Contain (Join-Path $downloadPath "TestApp.app") + Test-Path (Join-Path $downloadPath "MyApp.app") | Should -BeTrue + Test-Path (Join-Path $downloadPath "TestApp.app") | Should -BeTrue + } + + It 'Returns empty array for non-existent local paths' { + # Use a path that works cross-platform (nested Join-Path for PS5 compatibility) + $nonExistentPath = Join-Path (Join-Path $downloadPath "NonExistent") "MyApp.app" + $env:Settings = @{ + installApps = @($nonExistentPath) + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -HaveCount 0 + } + + It 'Downloads apps from URLs' { + $env:Settings = @{ + installApps = @("https://example.com/App1.app") + installTestApps = @("https://example.com/TestApp1.app") + } | ConvertTo-Json -Depth 10 + + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3)) + } -ModuleName DownloadProjectDependencies + + $result = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $result.Apps | Should -HaveCount 1 + $result.TestApps | Should -HaveCount 1 + } + + It 'Replaces secret placeholders in URLs' { + $env:Settings = @{ + installApps = @('https://example.com/App.app?token=${{ mySecret }}') + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + # Base64 encode the secret value + $secretValue = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("secret-token-value")) + $env:Secrets = @{ + mySecret = $secretValue + } | ConvertTo-Json -Depth 10 + + $script:capturedUrl = $null + Mock Invoke-WebRequest { + param($Method, $UseBasicParsing, $Uri, $OutFile) + $script:capturedUrl = $Uri + [System.IO.File]::WriteAllBytes($OutFile, [byte[]](1, 2, 3)) + } -ModuleName DownloadProjectDependencies + + $null = Get-DependenciesFromInstallApps -DestinationPath $downloadPath + + $script:capturedUrl | Should -Be "https://example.com/App.app?token=secret-token-value" + } + + It 'Throws error for unknown secret reference' { + $env:Settings = @{ + installApps = @('https://example.com/App.app?token=${{ unknownSecret }}') + installTestApps = @() + } | ConvertTo-Json -Depth 10 + + $env:Secrets = @{} | ConvertTo-Json -Depth 10 + + { Get-DependenciesFromInstallApps -DestinationPath $downloadPath } | Should -Throw "*unknown secret 'unknownSecret'*" + } +}