diff --git a/Actions/.Modules/settings.schema.json b/Actions/.Modules/settings.schema.json index b5d8a1b6e8..f716638706 100644 --- a/Actions/.Modules/settings.schema.json +++ b/Actions/.Modules/settings.schema.json @@ -744,6 +744,7 @@ "type": "object", "properties": { "filesToInclude": { + "description": "An array of file specifications to include in the update. Files that match these specifications are copied from the template to the repository. When used in a custom template's settings, inclusions are also propagated from the original template to consumer repos even if the files no longer exist in the custom template.", "type": "array", "items": { "type": "object", @@ -760,6 +761,10 @@ "type": "string", "description": "The destination folder where the files should be updated, relative to the repository root. If not specified, defaults to the same as the source file folder." }, + "destinationName": { + "type": "string", + "description": "The filename to use at the destination. If specified, overrides the source filename, allowing the file to be renamed when copied. Should be used together with a filter that matches a single file." + }, "perProject": { "type": "boolean", "description": "Indicates whether the file update should be applied per project. In that case, the destinationFolder is considered relative to each project folder." @@ -768,6 +773,7 @@ } }, "filesToExclude": { + "description": "An array of file specifications to exclude from the update. Files that match these specifications are not copied from the template to the repository. When used in a custom template's settings, exclusions are also propagated from the original template to consumer repos even if the files no longer exist in the custom template.", "type": "array", "items": { "type": "object", diff --git a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 index 29e6dbdb09..b527efa807 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -772,6 +772,117 @@ function UpdateSettingsFile { return $modified } +function GetPathStringComparison { + if ($PSVersionTable.PSVersion.Major -ge 6 -and $IsLinux) { + return [System.StringComparison]::Ordinal + } + else { + return [System.StringComparison]::OrdinalIgnoreCase + } +} + +function GetPathStringComparer { + if ((GetPathStringComparison) -eq [System.StringComparison]::Ordinal) { + return [System.StringComparer]::Ordinal + } + else { + return [System.StringComparer]::OrdinalIgnoreCase + } +} + +<# +.SYNOPSIS +Checks whether a path is physically contained within a root folder, resolving symlinks/junctions. +.DESCRIPTION +Verifies that $Path is located under $RootFolder both lexically and after resolving any +symbolic links or junctions along the way, protecting against paths that escape the root +folder via reparse points. Both parameters are treated as literal paths (no wildcard expansion). +.PARAMETER Path +The literal path to check. +.PARAMETER RootFolder +The literal root folder that $Path must be contained within. +.OUTPUTS +$true if the path is physically contained within the root folder, otherwise $false. +#> +function Test-PathPhysicallyContained { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [Parameter(Mandatory=$true)] + [string] $RootFolder + ) + + $pathComparison = GetPathStringComparison + + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + $RootFolder = [System.IO.Path]::GetFullPath($RootFolder) # canonicalize the root folder to an absolute path + $RootFolder = Join-Path $RootFolder '' # ensure the root folder path ends with a directory separator + + # Early exit if the path is obviously outside the root folder lexically + if (-not $Path.StartsWith($RootFolder, $pathComparison)) { + return $false + } + $resolveReparsePoints = $true # once an ancestor doesn't exist, no deeper segment can be a reparse point either + $hopLimit = 40 # matches the classic OS/.NET max-followed-symlinks limit (guards against cyclic chains) + $hopCount = 0 + + # List of verified paths that have been confirmed to contain no unresolved reparse points. + $verifiedPaths = [System.Collections.Generic.List[string]]::new() + $verifiedPaths.Add($RootFolder) + + # Initialize the work queue of remaining path segments to walk. + $segments = [System.Collections.Generic.List[string]]::new() + $segments.AddRange([string[]] $Path.Substring($RootFolder.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + + $realPath = $RootFolder + + while ($segments.Count -gt 0) { + $realPath = Join-Path $realPath $segments[0] + $segments.RemoveAt(0) + + if (-not $resolveReparsePoints) { + continue + } + if (-not (Test-Path -LiteralPath $realPath)) { + $resolveReparsePoints = $false + continue + } + + $item = Get-Item -LiteralPath $realPath -Force + if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { + $verifiedPaths.Add((Join-Path $realPath '')) # this segment itself is confirmed not a reparse point + continue + } + if ($hopCount++ -gt $hopLimit) { + # Cyclic or pathologically deep chain - fail closed, like the OS would refuse to resolve it too + OutputWarning "Path '$Path' could not be resolved: reparse point chain exceeded $($hopLimit) hops (cyclic or too deep) at '$realPath'. Treating as not contained." + return $false + } + + $target = @($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($target)) { + $target = Join-Path (Split-Path -Path $realPath -Parent) $target + } + $target = [System.IO.Path]::GetFullPath($target) + + # Find the longest verified path that is a prefix of the target. This helps to minimize redundant checks for already verified path segments. + $verifiedPath = $verifiedPaths | Sort-Object -Descending | Where-Object { $target.StartsWith($_, $pathComparison) } | Select-Object -First 1 + if (-not $verifiedPath) { + # If no verified path matches the target, start verification from the root of the target path. + $verifiedPath = [System.IO.Path]::GetPathRoot($target) + } + + # Re-inject every unverified segment of the resolved target so an embedded reparse point (e.g. link1 -> + # "link2/sub" where link2 itself escapes the root) gets its own check on a later iteration. + $segments.InsertRange(0, [string[]] $target.Substring($verifiedPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $verifiedPath + } + + $realPath = [System.IO.Path]::GetFullPath($realPath) + + return $realPath.StartsWith($RootFolder, $pathComparison) +} + <# .SYNOPSIS Resolves file paths based on the provided source folder, destination folder, and file specifications. @@ -780,6 +891,7 @@ Resolves file paths based on the provided source folder, destination folder, and This function takes a source folder, an optional original source folder, a destination folder, and an array of file specifications. It resolves the full paths for each specified file, considering their origin (template or custom template), type, and whether they are per-project files. The function returns an array of hashtables containing the resolved source and destination file paths. The function is used to determine which files need to be copied from the template repository to the target repository during the AL-Go update process. +Destination boundary checks are symlink/junction-aware (see Test-PathPhysicallyContained): a destination path is only accepted if it is both lexically and physically (after resolving reparse points) contained within the destination folder. sourceFolder: The base folder of the template used to resolve the source file paths. originalSourceFolder: The base folder of the original template used to check for original files (can be $null). This is in the case of custom templates, where if the file exists in the original template, it should be used instead of the custom template file. destinationFolder: The base folder used to construct the destination file paths. This is typically the root folder of the target repository. @@ -827,7 +939,16 @@ function ResolveFilePaths { return @() } + $sourceFolder = [System.IO.Path]::GetFullPath($sourceFolder) # Canonicalize the source folder to an absolute path + $sourceFolder = Join-Path $sourceFolder '' # Ensure source folder has a trailing slash for correct path resolution + + $destinationFolder = [System.IO.Path]::GetFullPath($destinationFolder) # Canonicalize the destination folder to an absolute path + $destinationFolder = Join-Path $destinationFolder '' # Ensure destination folder has a trailing slash for correct path resolution + + $pathComparer = GetPathStringComparer + $fullFilePaths = @() + $destinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { $file.sourceFolder = '' # Default to current folder @@ -879,8 +1000,8 @@ function ResolveFilePaths { 'destinationFullPath' = $null } - # Check if the source file is under the source folder - if ($srcFile -notlike "$sourceFolder*") { + # Check if the source file is under the source folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $srcFile -RootFolder $sourceFolder)) { OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." continue } @@ -889,12 +1010,12 @@ function ResolveFilePaths { # Try to find the same files in the original template folder if it is specified. Exclude custom template files if ($originalSourceFolder -and ($file.origin -ne 'custom template')) { - Push-Location $sourceFolder - $relativePath = Resolve-Path -Path $srcFile -Relative # resolve the path relative to the current location (template folder) - Pop-Location - if (Test-Path (Join-Path $originalSourceFolder $relativePath) -PathType Leaf) { + $relativeSourceFile = $srcFile.Substring($sourceFolder.Length) + $originalSourceFile = Join-Path $originalSourceFolder $relativeSourceFile + $originalSourceFile = [System.IO.Path]::GetFullPath($originalSourceFile) + if (Test-Path -LiteralPath $originalSourceFile -PathType Leaf) { # If the file exists in the original template folder, use that file instead - $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve + $fullFilePath.originalSourceFullPath = $originalSourceFile } } @@ -912,13 +1033,44 @@ function ResolveFilePaths { $project = '' # If project is '.', it means the root folder, so we use an empty string } + $unresolvedProjectDestinationFolder = Join-Path $destinationFolder $project + $unresolvedProjectDestinationFolder = Join-Path $unresolvedProjectDestinationFolder '' # Ensure unresolved project destination folder has a trailing slash for correct path resolution + $projectDestinationFolder = [System.IO.Path]::GetFullPath($unresolvedProjectDestinationFolder) # Canonicalize the unresolved project destination folder to an absolute path + $projectDestinationFolder = Join-Path $projectDestinationFolder '' # Ensure project destination folder has a trailing slash for correct path resolution + + # Check if the unresolved project destination folder resolves to the same absolute path (e.g. catches ".." and "." segments) + if ($unresolvedProjectDestinationFolder -ne $projectDestinationFolder) { + OutputWarning "Skipping file '$srcFile' for project '$project': project destination folder '$unresolvedProjectDestinationFolder' resolves to a different path '$projectDestinationFolder'." + continue + } + + # Check if the project destination folder is under the base destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $projectDestinationFolder -RootFolder $destinationFolder)) { + OutputWarning "Skipping file '$srcFile' for project '$project': project destination folder '$projectDestinationFolder' is outside the base destination folder '$destinationFolder'." + continue + } + + $fileDestinationFolder = Join-Path $projectDestinationFolder $file.destinationFolder + $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path + $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + + # Check if the destination folder is under the project destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fileDestinationFolder -RootFolder $projectDestinationFolder)) { + OutputWarning "Skipping file '$srcFile' for project '$project': destination folder '$fileDestinationFolder' is outside the project destination folder '$projectDestinationFolder'." + continue + } + $fullProjectFilePath = $fullFilePath.Clone() + $fullProjectFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName + $fullProjectFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullProjectFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName + # Check if the destination file is under the file destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fullProjectFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." + continue + } - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { + if(-not $destinationFullPaths.Add($fullProjectFilePath.destinationFullPath)) { OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" continue } @@ -930,10 +1082,26 @@ function ResolveFilePaths { # Single file entry # Destination full path is the destination base folder + destinationFolder + destinationName - $fullFilePath.destinationFullPath = Join-Path $destinationFolder $file.destinationFolder - $fullFilePath.destinationFullPath = Join-Path $fullFilePath.destinationFullPath $destinationName + $fileDestinationFolder = Join-Path $destinationFolder $file.destinationFolder + $fileDestinationFolder = [System.IO.Path]::GetFullPath($fileDestinationFolder) # Canonicalize the file destination folder to an absolute path + $fileDestinationFolder = Join-Path $fileDestinationFolder '' # Ensure file destination folder has a trailing slash for correct path resolution + + # Check if the destination folder is under the base destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fileDestinationFolder -RootFolder $destinationFolder)) { + OutputWarning "Skipping file '$srcFile': destination folder '$fileDestinationFolder' is outside the base destination folder '$destinationFolder'." + continue + } + + $fullFilePath.destinationFullPath = Join-Path $fileDestinationFolder $destinationName + $fullFilePath.destinationFullPath = [System.IO.Path]::GetFullPath($fullFilePath.destinationFullPath) # Canonicalize the destination full path to an absolute path - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { + # Check if the destination file is under the file destination folder (symlink/junction-aware) + if (-not (Test-PathPhysicallyContained -Path $fullFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." + continue + } + + if(-not $destinationFullPaths.Add($fullFilePath.destinationFullPath)) { OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" continue } @@ -994,12 +1162,70 @@ function GetDefaultFilesToExclude { return @($filesToExclude) } +<# +.SYNOPSIS + Reads settings using the current custom template repository settings without changing the workspace. +.DESCRIPTION + Temporarily refreshes the custom template repository settings snapshot, reads the merged settings, and restores + the snapshot to its original state. This allows the current template settings to affect the current run while + preserving the workspace state for the normal update comparison. +.PARAMETER baseFolder + The base folder of the repository whose settings are read. +.PARAMETER templateFolder + The folder where the custom template files are located. +#> +function ReadSettingsWithCurrentCustomTemplateRepoSettings { + Param( + [Parameter(Mandatory=$true)] + [string] $baseFolder, + [Parameter(Mandatory=$true)] + [string] $templateFolder + ) + + $templateFolderRepoSettingsPath = Join-Path $templateFolder $RepoSettingsFile + + $baseFolderTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $baseFolderTemplateSettingsBackupPath = $null + + if (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + $baseFolderTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + Copy-Item -LiteralPath $baseFolderTemplateSettingsPath -Destination $baseFolderTemplateSettingsBackupPath -Force + } + + try { + if (Test-Path -LiteralPath $templateFolderRepoSettingsPath -PathType Leaf) { + Copy-Item -LiteralPath $templateFolderRepoSettingsPath -Destination $baseFolderTemplateSettingsPath -Force + } + return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } + finally { + if ($baseFolderTemplateSettingsBackupPath) { + Copy-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Destination $baseFolderTemplateSettingsPath -Force + Remove-Item -LiteralPath $baseFolderTemplateSettingsBackupPath -Force + } + elseif (Test-Path -LiteralPath $baseFolderTemplateSettingsPath -PathType Leaf) { + Remove-Item -LiteralPath $baseFolderTemplateSettingsPath -Force + } + } +} + <# .SYNOPSIS Get the list of files from the template repository to include and exclude based on the provided settings. .DESCRIPTION - This function gets the list of files to include and exclude based on the provided settings. - The unusedALGoSystemFiles setting is also applied to exclude files from the include list and add them to the exclude list. + Builds two lists by merging defaults, repository settings, and the original AL-Go template (if given): + + 1. filesToInclude: Files to copy from the template or original template to the destination. + Built from default files to include and customALGoFiles.filesToInclude in settings, resolved against the template folder and original template folder (if any). + 2. filesToExclude: Files to skip from copying; if they already exist in the destination they should be deleted. + Built from default files to exclude and customALGoFiles.filesToExclude in settings, resolved against the template folder and original template folder (if any). + + Note: when a custom template is in use, the caller is expected to call + ReadSettingsWithCurrentCustomTemplateRepoSettings before this function, so that the template's + customALGoFiles/unusedALGoSystemFiles are already merged into settings. + + The deprecated unusedALGoSystemFiles setting is also applied: matching files are moved from filesToInclude to + filesToExclude with a deprecation warning. .PARAMETER settings The settings object containing the customALGoFiles configuration. .PARAMETER baseFolder @@ -1007,15 +1233,17 @@ function GetDefaultFilesToExclude { .PARAMETER templateFolder The folder where the template files are located. .PARAMETER originalTemplateFolder - The folder where the original template files are located (if any). - If originalTemplateFolder is provided, it means that there is a custom template in use and custom template files should be included. + The folder where the original AL-Go template files are located (if any). + When provided, it signals that a custom template is in use. Both filesToInclude and filesToExclude specs are + resolved against this folder in addition to templateFolder; entries not already covered by originalSourceFullPath + tracking are appended to propagate upstream template additions and deletions to consumer repositories. .PARAMETER projects The list of projects in the repository. The projects are used to resolve per-project files. .OUTPUTS An array containing two elements: the list of files to include and the list of files to exclude. Files are represented as hashtables with the following keys: - - sourceFullPath: The full path to the source file in the template repository. + - sourceFullPath: The full path to the source file. - originalSourceFullPath: The full path to the original source file in the original template repository (if any). - type: The type of the file (e.g., workflow, settings). - destinationFullPath: The full path to the destination file in the target repository. @@ -1032,6 +1260,7 @@ function GetFilesToUpdate { $projects = @() ) + $hasOriginalTemplate = $null -ne $originalTemplateFolder Write-Host "Getting files to update from template folder '$templateFolder', original template folder '$originalTemplateFolder' and base folder '$baseFolder'" # Send telemetery about customALGoFiles usage @@ -1042,46 +1271,58 @@ function GetFilesToUpdate { Trace-Information -Message "Usage: Custom AL-Go Files (Exclude)" } - $filesToInclude = GetDefaultFilesToInclude -includeCustomTemplateFiles:$($null -ne $originalTemplateFolder) - $filesToInclude += $settings.customALGoFiles.filesToInclude - $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToInclude -projects $projects) + $pathComparer = GetPathStringComparer - $filesToExclude = GetDefaultFilesToExclude -settings $settings - $filesToExclude += $settings.customALGoFiles.filesToExclude - $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExclude -projects $projects) + # Determine files to include + $filesToIncludeUnresolved = GetDefaultFilesToInclude -includeCustomTemplateFiles:$hasOriginalTemplate + $filesToIncludeUnresolved += $settings.customALGoFiles.filesToInclude + $filesToInclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + if ($hasOriginalTemplate) { + $filesToInclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToIncludeUnresolved -projects $projects) + } + # Deduplicate files to include based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) + $filesToIncludeDestinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $filesToInclude = @($filesToInclude | Where-Object { $filesToIncludeDestinationFullPaths.Add($_.destinationFullPath) }) - # Exclude files from filesToExclude that are not in filesToInclude - $filesToExclude = @($filesToExclude | Where-Object { - $fileToExclude = $_ - $include = $filesToInclude | Where-Object { $_.sourceFullPath -eq $fileToExclude.sourceFullPath } - if(-not $include) { - OutputDebug "Excluding file $($fileToExclude.sourceFullPath) from exclude list as it is not in the include list" - } - return $include + # Determine files to exclude + $filesToExcludeUnresolved = GetDefaultFilesToExclude -settings $settings + $filesToExcludeUnresolved += $settings.customALGoFiles.filesToExclude + $filesToExclude = @(ResolveFilePaths -sourceFolder $templateFolder -originalSourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + if ($hasOriginalTemplate) { + $filesToExclude += @(ResolveFilePaths -sourceFolder $originalTemplateFolder -destinationFolder $baseFolder -files $filesToExcludeUnresolved -projects $projects) + } + # filesToExclude is not deduplicated by destinationFullPath here. + # Its destinationFullPath is never part of the actual output; only sourceFullPath is used below to match against filesToInclude. + + # Map files from filesToExclude to files that are in filesToInclude (based on source) + # Settings for filesToExclude only define the sources (sourceFolder and filter) but not the destinations (destinationFolder, destinationName and perProject) + $filesToExclude = @($filesToInclude | Where-Object { + $fileToInclude = $_ + return $filesToExclude | Where-Object { $pathComparer.Equals($_.sourceFullPath, $fileToInclude.sourceFullPath) } }) - # Exclude files from filesToInclude that are in filesToExclude + # Exclude files from filesToInclude that are in filesToExclude (based on source) $filesToInclude = @($filesToInclude | Where-Object { - $fileToInclude = $_ - $include = -not ($filesToExclude | Where-Object { $_.sourceFullPath -eq $fileToInclude.sourceFullPath }) - if(-not $include) { - OutputDebug "Excluding file $($fileToInclude.sourceFullPath) from include as it is in the exclude list" - } + $file = $_ + $include = -not ($filesToExclude | Where-Object { $pathComparer.Equals($_.sourceFullPath, $file.sourceFullPath) }) + if (-not $include) { OutputDebug "Excluding source file '$($file.sourceFullPath)' from include list as it is in the exclude list" } return $include }) # Apply unusedALGoSystemFiles logic $unusedALGoSystemFiles = $settings.unusedALGoSystemFiles + $unusedALGoSystemFileNames = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $unusedALGoSystemFileNames.UnionWith([string[]]$unusedALGoSystemFiles) # Exclude unusedALGoSystemFiles from $filesToInclude and add them to $filesToExclude - $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFiles -contains (Split-Path -Path $_.sourceFullPath -Leaf) } + $unusedFilesToExclude = $filesToInclude | Where-Object { $unusedALGoSystemFileNames.Contains((Split-Path -Path $_.sourceFullPath -Leaf)) } if ($unusedFilesToExclude) { Trace-DeprecationWarning "The 'unusedALGoSystemFiles' setting is deprecated and will be removed in future versions." -DeprecationTag "unusedALGoSystemFiles" OutputDebug "The following files are marked as unused and will be removed if they exist:" $unusedFilesToExclude | ForEach-Object { OutputDebug "- $($_.destinationFullPath)" } - $filesToInclude = @($filesToInclude | Where-Object { $unusedALGoSystemFiles -notcontains (Split-Path -Path $_.sourceFullPath -Leaf) }) + $filesToInclude = @($filesToInclude | Where-Object { -not $unusedALGoSystemFileNames.Contains((Split-Path -Path $_.sourceFullPath -Leaf)) }) $filesToExclude += @($unusedFilesToExclude) } diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 46a95da706..3025bf2f6b 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -52,7 +52,7 @@ if ($token) { # if $downloadLatest is set to true, CheckForUpdates will download the latest version of the template repository, else it will use the templateSha setting in the .github/AL-Go-Settings file # Get Repo settings as a hashtable (do NOT read any specific project settings, nor any specific workflow, user or branch settings) -$repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' | ConvertTo-HashTable -recurse +$repoSettings = ReadSettings -buildMode '' -project '' -workflowName '' -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse $templateSha = $repoSettings.templateSha # If templateUrl has changed, download latest version of the template repository (ignore templateSha) @@ -113,6 +113,12 @@ if (-not $isDirectALGo) { # Get the list of projects in the current repository $baseFolder = $ENV:GITHUB_WORKSPACE + +if ($originalTemplateFolder) { + # Use current custom template settings for this run without changing the workspace before comparison. + $repoSettings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder +} + $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2c8f7cbaaa..c6d09720f8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,3 +1,13 @@ +### Enhanced `customALGoFiles` setting + +The `customALGoFiles` setting of a custom template was only applied on the next Update (from `AL-Go-TemplateRepoSettings.doNotEdit.json`). Now the up-to-date settings of the custom template are used directly during "Update AL-Go System Files". The template's `filesToInclude` and `filesToExclude` settings are merged with the consumer repo's settings before resolution. + +- **`filesToInclude`** now also resolves files from the original AL-Go template. Files present in the official template are propagated even when they are absent from your custom template. When a file exists in both, the official template supplies the base content; for workflow files, customizations from the custom template are reapplied. +- **`filesToExclude`** now also resolves files from the original AL-Go template (same dual-resolution as `filesToInclude`). Files resolved by `filesToInclude` whose source matches a `filesToExclude` entry are not copied to consumer repos, and existing copies are removed. +- **`destinationName`** (new property on `filesToInclude`): Allows renaming a file at the destination. When set, the file is written to `/` instead of keeping the source filename. + +Read more at [Customizing AL-Go for GitHub](Scenarios/CustomizingALGoForGitHub.md#Using-custom-template-files). + ### New `unpublishOldVersions` setting for deployment The `DeployTo` setting now supports an opt-in `unpublishOldVersions` boolean (default `false`). When enabled, AL-Go unpublishes old, uninstalled versions of the deployed apps from the environment after a successful deployment, keeping Extension Management clean. This only applies to PTE deployments (Scope PTE / automation API), uses the Automation API v2.0 `Microsoft.NAV.unpublish` action, and is non-fatal (failures are reported as warnings and never fail the deployment). diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index f5d80b24ad..b8c7c581e3 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -239,10 +239,25 @@ In order to instruct AL-Go which files to look for at the template repository, y - `filter`: A string to use for filtering in the specified source path. It can contain `*` and `?` wildcards. _Example_: `*.ps1` or `fileToUpdate.ps1`. - `destinationFolder`: A path to a folder, relative to repository that is being updated, where the files should be placed. If not specified, defaults to the same as the source file folder. _Example_: `src/templateScripts`. - `perProject`: A boolean that indicates whether the matched files should be propagated for all available AL-Go projects. In that case, `destinationFolder` is relative to the project folder. _Example_: `.AL-Go/scripts`. +- `destinationName`: The filename to use at the destination. If specified, overrides the source filename, allowing the file to be renamed when copied. Should be used together with a `filter` that matches a single file. _Example_: `customScript.ps1`. > [!NOTE] > `filesToInclude` is used to define all the template files that will be used by AL-Go for GitHub. If a template file is not matched, it will be ignored. Please pay attention, when changing the file configurations: there might be template files that were previously propagated to your repositories. In case these files are no longer matched via `filesToInclude`, AL-Go for GitHub will ignore them and you might have to remove them manually. +When using a custom template repository, `filesToInclude` also resolves files from the **original** AL-Go template (i.e. the official [AL-Go-PTE](https://github.com/microsoft/AL-Go-PTE) or [AL-Go-AppSource](https://github.com/microsoft/AL-Go-AppSource) template). This means files present in the official AL-Go template that are not overridden by your custom template are still propagated to consumer repositories. When a file exists in both the original template and your custom template, how the file's **content** is resolved depends on the file's type: + +- **Workflow files** (`.github/workflows/*.yaml`/`*.yml`): the content is based on the original template's file, with customizations from your custom template's copy (see [Adding custom jobs](#adding-custom-jobs)) re-applied on top. +- **Settings files** and **all other files** (e.g. PowerShell scripts, `.copy.md`, `.agent.md`): the original template's file content is used as-is; changes made to that same file in your custom template are not applied in this case. + +The following table summarizes how `filesToInclude` resolves files when a custom template is in use: + +| File is present in original template | File is present in custom template | File is matched by `filesToInclude` | Result | +|---|---|---|---| +| Yes | No | Yes | File from **original template** is propagated | +| No | Yes | Yes | File from **custom template** is propagated | +| Yes | Yes | Yes | File from **original template** is propagated; for Workflow files, customizations from the **custom template** are also applied | +| Yes/No | Yes/No | No | File is **ignored** | + `filesToExclude` is an array of file configurations that will instruct AL-Go which files to exclude (remove) from `filesToInclude`. Every item in the array may contain the following properties: - `sourceFolder`: A path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. _Example_: `src/scripts`. @@ -251,13 +266,16 @@ In order to instruct AL-Go which files to look for at the template repository, y > [!NOTE] `filesToExclude` is an array of file configurations already included in `filesToInclude`. These files are specifically marked to be excluded from the update process. > This mechanism allows for fine-grained control over which files are propagated to the end repository and which should be explicitly removed, ensuring that unwanted files are not carried forward during updates. +> [!TIP] +> When using a custom template repository, you can use `filesToExclude` in the custom template's settings to prevent files from the original AL-Go template from being propagated to consumer repos. For example, if the original template includes a workflow you don't want in your consumer repos, adding it to `filesToExclude` in your custom template's settings will remove it during the next update. + The following table summarizes how AL-Go for GitHub manages file updates and exclusions when using custom template files. Say, there is a file (e.g. `file.ps1`) in the template repository. | File is present in end repo | File is matched by `filesToInclude` | File is matched by `filesToExclude` | Result | |---|---|---|---| | Yes/No | Yes | No | The file is **updated/created** in the end repo | | Yes | Yes | Yes | The file is **removed** from the end repo, as it's matched for exclusion | -| Yes | No | Yes | The files is **_not_** removed as it was not matched as update | +| Yes | No | Yes | The file is **_not_** removed as it was not matched as update | | No | Yes/No | Yes | The file is **_not_ created** in the end repo, as it's matched for exclusion | ### Examples of using custom template files diff --git a/Scenarios/settings.md b/Scenarios/settings.md index 7325ee3465..afe454fd1d 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -248,7 +248,7 @@ Please read the release notes carefully when installing new versions of AL-Go fo | BcContainerHelperVersion | This setting can be set to a specific version (ex. 3.0.8) of BcContainerHelper to force AL-Go to use this version. **latest** means that AL-Go will use the latest released version. **preview** means that AL-Go will use the latest preview version. **dev** means that AL-Go will use the dev branch of containerhelper. | latest (or preview for AL-Go preview) | | unusedALGoSystemFiles (**deprecated**) | An array of AL-Go System Files, which won't be updated during Update AL-Go System Files. They will instead be removed.
Use this setting with care, as this can break the AL-Go for GitHub functionality and potentially leave your repo no longer functional. | [ ] | | reportSuppressedDiagnostics | If this setting is set to true, the AL compiler will report diagnostics which are suppressed in the code using the pragma `#pragma warning disable `. This can be useful if you want to ensure that no warnings are suppressed in your code. | false | -| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. The object can contain properties `filesToInclude` and `filesToExclude`. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files). | `{ "filesToInclude": [], "filesToExclude": [] }` +| customALGoFiles | An object to configure custom AL-Go files, that will be updated during "Update AL-Go System Files" workflow. Read more at [Customizing AL-Go](CustomizingALGoForGitHub.md#Using-custom-template-files).
**filesToInclude** = an array of file specifications to include (create/update). Each item can contain **sourceFolder** (folder relative to the template root to look for files, default root folder), **filter** (filter string supporting `*` and `?` wildcards, default all files), **destinationFolder** (folder relative to the repository to place the files, default same as sourceFolder), **destinationName** (filename to use at the destination, overriding the source filename to rename the file when copied; should be used together with a filter matching a single file, default source filename), and **perProject** (boolean indicating whether the files should be propagated to all AL-Go projects, in which case destinationFolder is relative to the project folder, default false).
**filesToExclude** = an array of file specifications to exclude (remove) from `filesToInclude`. Each item can contain **sourceFolder** and **filter** (same meaning as above). | `{ "filesToInclude": [], "filesToExclude": [] }` ## Overwrite settings diff --git a/Tests/CheckForUpdates.Action.Test.ps1 b/Tests/CheckForUpdates.Action.Test.ps1 index c25ef39e0d..66249eb07c 100644 --- a/Tests/CheckForUpdates.Action.Test.ps1 +++ b/Tests/CheckForUpdates.Action.Test.ps1 @@ -4,6 +4,28 @@ Import-Module (Join-Path $PSScriptRoot "../Actions/TelemetryHelper.psm1") Import-Module (Join-Path $PSScriptRoot '../Actions/.Modules/ReadSettings.psm1') $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 +# Computed here (not in BeforeAll) because -Skip: expressions are evaluated at discovery time, and +# these variables are used by -Skip: expressions in Describe blocks throughout this file. +# $IsWindows doesn't exist in Windows PowerShell 5.1, which only ever runs on Windows anyway. +$script:isWindowsPlatform = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows +$script:isLinuxPlatform = ($PSVersionTable.PSVersion.Major -ge 6) -and $IsLinux + +# Determine if the runner has the capability to create symlinks +$script:hasSymlinkCapability = $true +if ($script:isWindowsPlatform) { + # Probe once whether this runner can create symlinks; Junctions never need this privilege, SymbolicLinks do + $probeLinkPath = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + try { + New-Item -ItemType SymbolicLink -Path $probeLinkPath -Target $PSScriptRoot -ErrorAction Stop | Out-Null + } + catch { + $script:hasSymlinkCapability = $false + } + finally { + Remove-Item -Path $probeLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + Describe "CheckForUpdates Action Tests" { BeforeAll { $actionName = "CheckForUpdates" @@ -363,6 +385,7 @@ Describe "CheckForUpdates Action: ApplyWorkflowDefaultInputs Tests" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") } @@ -1393,6 +1416,67 @@ Describe "ResolveFilePaths" { $fullFilePaths[1].type | Should -Be '' } + It 'ResolveFilePaths warns and skips destinations outside the destination folder' { + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $destinationSubfolder = Join-Path $destinationFolder "subfolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationName" = "../outside.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "destinationFolder" = "../outside" } + @{ "sourceFolder" = "folder"; "filter" = "File3.txt"; "destinationFolder" = "subfolder"; "destinationName" = "../outside.txt" } + @{ "sourceFolder" = "folder"; "filter" = "File4.md" } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File4.md") + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like "*outside the file destination folder '$destinationFolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the file destination folder '$destinationSubfolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the base destination folder '$destinationFolder*" } + } + + It 'ResolveFilePaths warns and skips per-project destinations outside the destination folder' { + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $destinationProjectFolder = Join-Path $destinationFolder "project" + $destinationProjectSubfolder = Join-Path $destinationProjectFolder "subfolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "destinationName" = "../outside.txt"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File2.log"; "destinationFolder" = "../outside"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File3.txt"; "destinationFolder" = "subfolder"; "destinationName" = "../outside.txt"; "perProject" = $true } + @{ "sourceFolder" = "folder"; "filter" = "File4.md"; "perProject" = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("project")) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File4.md") + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "project/folder/File4.md") + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like "*outside the file destination folder '$destinationProjectFolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the file destination folder '$destinationProjectSubfolder*" } + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*outside the project destination folder '$destinationProjectFolder*" } + } + + It 'ResolveFilePaths warns and skips per-project path outside the destination folder' -TestCases @( + @{ project = ".." } + @{ project = "project/.." } + ) { + param($project) + + $destinationFolder = Join-Path $rootFolder "destinationFolder" + $files = @( + @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "perProject" = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @($project)) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*for project '$project': project destination folder * resolves to a different path *" } + } + It 'ResolveFilePaths with type' { $destinationFolder = "destinationFolder" $destinationFolder = Join-Path $PSScriptRoot $destinationFolder @@ -1539,28 +1623,388 @@ Describe "ResolveFilePaths" { } It 'ResolveFilePaths skips files outside the source folder' { - # Create an external file outside the source folder $externalFolder = Join-Path $PSScriptRoot "external" - if (-not (Test-Path $externalFolder)) { New-Item -Path $externalFolder -ItemType Directory | Out-Null } $externalFile = Join-Path $externalFolder "outside.txt" - Set-Content -Path $externalFile -Value "outside" + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value "outside" - $destinationFolder = "destinationFolder" - $destinationFolder = Join-Path $PSScriptRoot $destinationFolder + $files = @( + @{ "sourceFolder" = "../external"; "filter" = "*.txt" } + ) + + # Intentionally call ResolveFilePaths with the real sourceFolder (so external file should not be included) + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + # Ensure none of the returned sourceFullPath entries point to the external file + $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -Be $externalFile } + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips files in folder whose name starts with source folder name' { + $externalFolder = "${sourceFolder}-external" + $externalFile = Join-Path $externalFolder "outside.txt" + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value "outside" + + $files = @( + @{ "sourceFolder" = "../sourceFolder-external"; "filter" = "*.txt" } + ) + + $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + + # The file in the prefix-colliding folder must NOT be included + $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -BeLike "${externalFolder}*" } + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips files in a source folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { + $externalFolder = Join-Path $rootFolder 'sourcefolder' + $externalFile = Join-Path $externalFolder 'outside.txt' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + Set-Content -Path $externalFile -Value 'outside' + + $files = @( + @{ 'sourceFolder' = '../sourcefolder'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + It 'ResolveFilePaths skips destinations in a folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( - @{ "sourceFolder" = "../external"; "filter" = "*.txt" } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = '../casefolder/outside.txt' } ) + Mock OutputWarning {} - # Intentionally call ResolveFilePaths with the real sourceFolder (so external file should not be included) - $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + + It 'ResolveFilePaths skips per-project destinations in a folder that differs only by case' -Skip:(-not $script:isLinuxPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../caseproject/outside.txt'; 'perProject' = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('CaseProject')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + + It 'ResolveFilePaths skips per-project entries when the project folder is a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $projectLinkPath = Join-Path $destinationFolder 'externalProject' + + try { + # 'sub' must exist under the escape target so the per-project checks don't short-circuit on a not-yet-existing path + New-Item -Path (Join-Path $externalFolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $projectLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('externalProject')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips per-project entries when the project folder is a symlink pointing outside the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $projectLinkPath = Join-Path $destinationFolder 'externalProject' + + try { + New-Item -Path (Join-Path $externalFolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $projectLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('externalProject')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves per-project entries when the project folder is a junction that stays within the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $projectLinkPath = Join-Path $destinationFolder 'internalProject' + New-Item -Path (Join-Path $destinationSubfolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $projectLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('internalProject')) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalProject/sub/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves per-project entries when the project folder is a symlink that stays within the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $projectLinkPath = Join-Path $destinationFolder 'internalProject' + New-Item -Path (Join-Path $destinationSubfolder 'sub') -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $projectLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'sub'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('internalProject')) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalProject/sub/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips destinations reachable only through a junction pointing outside the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationLinkPath = Join-Path $destinationFolder 'externalLink' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $destinationLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'externalLink' } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips destinations reachable only through a symlink pointing outside the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $externalFolder = Join-Path $rootFolder 'external' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationLinkPath = Join-Path $destinationFolder 'externalLink' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $destinationLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'externalLink' } + ) + Mock OutputWarning {} + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 + } + finally { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves destinations reachable through a junction that stays within the destination folder' -Skip:(-not $script:isWindowsPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $destinationLinkPath = Join-Path $destinationFolder 'internalLink' + New-Item -Path $destinationSubfolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $destinationLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'internalLink' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves destinations reachable through a symlink that stays within the destination folder' -Skip:(-not $script:hasSymlinkCapability) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $destinationSubfolder = Join-Path $destinationFolder 'subfolder' + $destinationLinkPath = Join-Path $destinationFolder 'internalLink' + New-Item -Path $destinationSubfolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $destinationLinkPath -Target $destinationSubfolder -Force | Out-Null + + try { + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'internalLink' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File1.txt') + } + finally { + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips source files reachable only through a junction pointing outside the source folder' -Skip:(-not $script:isWindowsPlatform) { + $externalFolder = Join-Path $rootFolder 'external' + $sourceFile = Join-Path $externalFolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'externalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType Junction -Path $sourceLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'externalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips source files reachable only through a symlink pointing outside the source folder' -Skip:(-not $script:hasSymlinkCapability) { + $externalFolder = Join-Path $rootFolder 'external' + $sourceFile = Join-Path $externalFolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'externalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $sourceLinkPath -Target $externalFolder -Force | Out-Null + $files = @( + @{ 'sourceFolder' = 'externalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths | Should -BeNullOrEmpty + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves source files reachable through a junction that stays within the source folder' -Skip:(-not $script:isWindowsPlatform) { + $sourceSubfolder = Join-Path $sourceFolder 'subfolder' + $sourceFile = Join-Path $sourceSubfolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'internalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $sourceSubfolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType Junction -Path $sourceLinkPath -Target $sourceSubfolder -Force | Out-Null + + $files = @( + @{ 'sourceFolder' = 'internalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - # Ensure none of the returned sourceFullPath entries point to the external file - $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -Be $externalFile } + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder 'internalLink/File.txt') + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File.txt') + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $sourceSubfolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths resolves source files reachable through a symlink that stays within the source folder' -Skip:(-not $script:hasSymlinkCapability) { + $sourceSubfolder = Join-Path $sourceFolder 'subfolder' + $sourceFile = Join-Path $sourceSubfolder 'File.txt' + $sourceLinkPath = Join-Path $sourceFolder 'internalLink' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + + try { + New-Item -Path $sourceSubfolder -ItemType Directory -Force | Out-Null + New-Item -Path $sourceFile -ItemType File -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $sourceLinkPath -Target $sourceSubfolder -Force | Out-Null - # Cleanup - if (Test-Path $externalFile) { Remove-Item -Path $externalFile -Force } - if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + $files = @( + @{ 'sourceFolder' = 'internalLink'; 'filter' = '*.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder 'internalLink/File.txt') + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'internalLink/File.txt') + } + finally { + Remove-Item -Path $sourceLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $sourceSubfolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $destinationFolder -Recurse -Force -ErrorAction SilentlyContinue + } } It 'ResolveFilePaths returns empty when no files match filter' { @@ -1630,6 +2074,34 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") } + It 'ResolveFilePaths keeps case-distinct destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 2 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeTrue + } + + It 'ResolveFilePaths removes case-distinct destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt' } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt' } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) + + $fullFilePaths.Count | Should -Be 1 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'casefolder/conflict.txt')) | Should -BeFalse + } + It 'ResolveFilePaths treats dot project as repository root for per-project files' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @@ -1782,6 +2254,34 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") } + It 'ResolveFilePaths keeps case-distinct per-project destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('ProjectA')) + + $fullFilePaths.Count | Should -Be 2 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeTrue + } + + It 'ResolveFilePaths removes case-distinct per-project destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'CaseFolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File2.log'; 'destinationFolder' = 'casefolder'; 'destinationName' = 'conflict.txt'; 'perProject' = $true } + ) + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('ProjectA')) + + $fullFilePaths.Count | Should -Be 1 + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/CaseFolder/conflict.txt')) | Should -BeTrue + ($fullFilePaths.destinationFullPath -ccontains (Join-Path $destinationFolder 'ProjectA/casefolder/conflict.txt')) | Should -BeFalse + } + It 'ResolveFilePaths handles empty sourceFolder value' { # Create a file in the root of the sourceFolder $rootFile = Join-Path $sourceFolder "RootFile.txt" @@ -1852,7 +2352,29 @@ Describe "ResolveFilePaths" { } } - It 'ResolveFilePaths with origin custom template and no originalSourceFolder skips files' { + It 'ResolveFilePaths resolves original paths containing wildcard characters literally' { + $destinationFolder = Join-Path $PSScriptRoot 'destinationFolder' + $sourceFile = Join-Path $sourceFolder 'folder/File[1].ps1' + $originalSourceFile = Join-Path $originalSourceFolder 'folder/File[1].ps1' + Set-Content -LiteralPath $sourceFile -Value '# source file' + Set-Content -LiteralPath $originalSourceFile -Value '# original source file' + $currentLocation = Get-Location + + try { + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files @(@{ sourceFolder = 'folder'; filter = '*.ps1' }) -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder) + $resolvedFile = @($fullFilePaths | Where-Object { $_.sourceFullPath -eq $sourceFile }) + + $resolvedFile.Count | Should -Be 1 + $resolvedFile[0].originalSourceFullPath | Should -Be $originalSourceFile + (Get-Location).Path | Should -Be $currentLocation.Path + } + finally { + Remove-Item -LiteralPath $sourceFile -Force + Remove-Item -LiteralPath $originalSourceFile -Force + } + } + + It 'ResolveFilePaths with origin custom template and no originalSourceFolder skips files' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; "origin" = "custom template" } @@ -1897,6 +2419,350 @@ Describe "ResolveFilePaths" { } } +Describe "Test-PathPhysicallyContained" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "physicallyContainedTests" + $externalFolder = Join-Path $PSScriptRoot "physicallyContainedTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + if (Test-Path $rootFolder) { + Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue + } + if (Test-Path $externalFolder) { + Remove-Item -Path $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true for a path lexically inside the root folder (no I/O involved)' { + $path = Join-Path $rootFolder "folder/file.txt" + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathPhysicallyContained returns false for a path lexically outside the root folder (no I/O involved)' { + $path = Join-Path $externalFolder "file.txt" + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + + It 'Test-PathPhysicallyContained returns true when trailing path segments do not exist yet' { + $internalFolderPath = Join-Path $rootFolder "folder" + $path = Join-Path $internalFolderPath "subfolder/file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Symbolic link tests (requires symlink capability) + + It 'Test-PathPhysicallyContained returns true through a single symlink that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $internalFolderPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a single symlink that points outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two symlinks landing inside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two symlinks going outside but landing back inside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $externalLinkPath2 = Join-Path $externalFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $internalFolderPath -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $externalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $externalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath1, $externalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a chain of two symlinks landing outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false when a symlink target embeds another symlink that escapes the root as a non-final segment' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $externalSubFolder = Join-Path $externalFolder "sub" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $externalSubFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + # link1's target textually starts under the root, but embeds link2 (which escapes the root) as a non-final segment + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target (Join-Path $internalLinkPath2 "sub") -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalSubFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a symlink with a relative target that stays within the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + Push-Location $rootFolder + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target "folder" -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Pop-Location + } + } + + It 'Test-PathPhysicallyContained returns false through a symlink with a relative target landing outside the root' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + Push-Location $rootFolder + New-Item -ItemType SymbolicLink -Path $internalLinkPath -Target "../physicallyContainedTestsExternal" -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + Pop-Location + } + } + + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic symlink pair' -Skip:(-not $script:hasSymlinkCapability) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalLinkPath1 -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $internalLinkPath2 -Target $internalLinkPath1 -Force | Out-Null + Remove-Item -Path $internalLinkPath1 -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType SymbolicLink -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + Mock OutputWarning {} + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "Path '$path' could not be resolved: reparse point chain exceeded * hops (cyclic or too deep) at '*'. Treating as not contained." } + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Junction tests (Windows only) + + It 'Test-PathPhysicallyContained returns true through a single junction that stays within the root' -Skip:(-not $script:isWindowsPlatform) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath -Target $internalFolderPath -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a single junction that points outside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath = Join-Path $rootFolder "link" + $path = Join-Path $internalLinkPath "file.txt" + try { + New-Item -ItemType Junction -Path $internalLinkPath -Target $externalFolder -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two junctions landing inside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath,$internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true through a chain of two junctions going outside but landing back inside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalFolderPath = Join-Path $rootFolder "folder" + $internalLinkPath1 = Join-Path $rootFolder "link1" + $externalLinkPath2 = Join-Path $externalFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $externalLinkPath2 -Target $internalFolderPath -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $externalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFolderPath, $internalLinkPath1, $externalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false through a chain of two junctions landing outside the root' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false when a junction target embeds another junction that escapes the root as a non-final segment' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $externalSubFolder = Join-Path $externalFolder "sub" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -Path $externalSubFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $externalFolder -Force | Out-Null + # link1's target textually starts under the root, but embeds link2 (which escapes the root) as a non-final segment + New-Item -ItemType Junction -Path $internalLinkPath1 -Target (Join-Path $internalLinkPath2 "sub") -Force | Out-Null + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalSubFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns false and completes without hanging for a cyclic junction pair' -Skip:(-not $script:isWindowsPlatform) { + $internalLinkPath1 = Join-Path $rootFolder "link1" + $internalLinkPath2 = Join-Path $rootFolder "link2" + $path = Join-Path $internalLinkPath1 "file.txt" + try { + New-Item -ItemType Directory -Path $internalLinkPath1 -Force | Out-Null + New-Item -ItemType Junction -Path $internalLinkPath2 -Target $internalLinkPath1 -Force | Out-Null + Remove-Item -Path $internalLinkPath1 -Recurse -Force + New-Item -ItemType Junction -Path $internalLinkPath1 -Target $internalLinkPath2 -Force | Out-Null + Mock OutputWarning {} + + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "Path '$path' could not be resolved: reparse point chain exceeded * hops (cyclic or too deep) at '*'. Treating as not contained." } + } + finally { + Remove-Item -Path $internalLinkPath1, $internalLinkPath2 -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Hard link tests (no elevation needed on any platform) + + It 'Test-PathPhysicallyContained returns true for a hard-linked file inside the root' { + $internalFilePath = Join-Path $rootFolder "file.txt" + $internalLinkPath = Join-Path $rootFolder "link.txt" + $path = $internalLinkPath + try { + New-Item -ItemType File -Path $internalFilePath -Force | Out-Null + New-Item -ItemType HardLink -Path $internalLinkPath -Target $internalFilePath -Force | Out-Null + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $internalFilePath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyContained returns true for a hard-linked file outside the root' { + $externalFilePath = Join-Path $externalFolder "file.txt" + $internalLinkPath = Join-Path $rootFolder "link.txt" + $path = $internalLinkPath + try { + New-Item -ItemType File -Path $externalFilePath -Force | Out-Null + New-Item -ItemType HardLink -Path $internalLinkPath -Target $externalFilePath -Force | Out-Null + Test-PathPhysicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + finally { + Remove-Item -Path $externalFilePath, $internalLinkPath -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + Describe "ReplaceOwnerRepoAndBranch" { BeforeAll { $actionName = "CheckForUpdates" @@ -1966,15 +2832,83 @@ Describe "GetFilesToUpdate (general files to update logic)" { # . # ├── test.ps1 # ├── test.txt - # └── test2.txt + # ├── test2.txt + # └── subfolder + # ├── testsub.txt + # └── testsub2.txt + + $originalTemplateFolder = Join-Path $PSScriptRoot "originalTemplate" + Copy-Item -Path $templateFolder -Destination $originalTemplateFolder -Recurse -Force | Out-Null + + $testOriginalTemplateTxtFile = Join-Path $originalTemplateFolder "test.original.txt" + Set-Content -Path $testOriginalTemplateTxtFile -Value "test original template txt file" + + $testOriginalTemplatePSFile = Join-Path $originalTemplateFolder "test.original.ps1" + Set-Content -Path $testOriginalTemplatePSFile -Value "# test original template ps file" + + # Display the created files structure for original template folder + # . + # ├── test.ps1 + # ├── test.txt + # ├── test2.txt + # ├── test.original.ps1 + # ├── test.original.txt # └── subfolder - # └── testsub.txt + # ├── testsub.txt + # └── testsub2.txt + + $baseFolder = Join-Path $PSScriptRoot "base" + Copy-Item -Path $templateFolder -Destination $baseFolder -Recurse -Force | Out-Null + + $testBaseTxtFile = Join-Path $baseFolder "test.base.txt" + Set-Content -Path $testBaseTxtFile -Value "test base txt file" + + $testBasePSFile = Join-Path $baseFolder "test.base.ps1" + Set-Content -Path $testBasePSFile -Value "# test base ps file" + + $baseProject1Folder = Join-Path $baseFolder "project1" + Copy-Item -Path $templateFolder -Destination $baseProject1Folder -Recurse -Force | Out-Null + + $baseProject2Folder = Join-Path $baseFolder "project2" + Copy-Item -Path $templateFolder -Destination $baseProject2Folder -Recurse -Force | Out-Null + + Remove-Item -Path (Join-Path $baseFolder 'test2.txt') -Recurse -Force | Out-Null + + # Display the created files structure for base folder + # . + # ├── test.ps1 + # ├── test.txt + # ├── test.base.ps1 + # ├── test.base.txt + # ├── subfolder + # │ ├── testsub.txt + # │ └── testsub2.txt + # ├── project1 + # │ ├── test.ps1 + # │ ├── test.txt + # │ ├── test2.txt + # │ └── subfolder + # │ ├── testsub.txt + # │ └── testsub2.txt + # └── project2 + # ├── test.ps1 + # ├── test.txt + # ├── test2.txt + # └── subfolder + # ├── testsub.txt + # └── testsub2.txt } AfterAll { if (Test-Path $templateFolder) { Remove-Item -Path $templateFolder -Recurse -Force } + if (Test-Path $originalTemplateFolder) { + Remove-Item -Path $originalTemplateFolder -Recurse -Force + } + if (Test-Path $baseFolder) { + Remove-Item -Path $baseFolder -Recurse -Force + } } It "Returns the correct files to update with filters" { @@ -1987,14 +2921,14 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.ps1') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.ps1') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty $settings = @{ @@ -2006,15 +2940,15 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty } @@ -2028,16 +2962,16 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test2.txt') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty $settings = @{ @@ -2049,18 +2983,18 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test.txt') # One file to remove $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be $testTxtFile2 - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test2.txt') } It 'Returns the correct files with destinationName' { @@ -2073,14 +3007,14 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'renamed.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'renamed.txt') - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty $settings = @{ @@ -2092,12 +3026,12 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'dstPath/renamed.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'dstPath/renamed.txt') } It 'Return the correct files with types' { @@ -2110,15 +3044,15 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testPSFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.ps1') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.ps1') $filesToInclude[0].type | Should -Be "script" - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty $settings = @{ @@ -2130,19 +3064,19 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 1 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') $filesToInclude[0].type | Should -Be "text" # One file to remove $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') } It 'Return the correct files when unusedALGoSystemFiles is specified' { @@ -2155,20 +3089,20 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') # One file to remove $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be $testPSFile - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.ps1') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.ps1') } It 'GetFilesToUpdate with perProject true and empty projects returns no per-project entries' { @@ -2182,7 +3116,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } # Pass empty projects array - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder -projects @() + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects @() # Behavior: when projects is empty, no per-project entries should be created $filesToInclude | Should -BeNullOrEmpty @@ -2199,15 +3133,15 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # All txt files should be included, no files to exclude $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 $filesToInclude[0].sourceFullPath | Should -Be $testTxtFile - $filesToInclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test.txt') + $filesToInclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') $filesToInclude[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') $filesToExclude | Should -BeNullOrEmpty } @@ -2227,13 +3161,13 @@ Describe "GetFilesToUpdate (general files to update logic)" { } $projects = @('.', 'ProjectOne') - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder -projects $projects + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -projects $projects $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 2 - $rootDestination = Join-Path 'baseFolder' 'custom/perProjectFile.algo' - $projectDestination = Join-Path 'baseFolder' 'ProjectOne/custom/perProjectFile.algo' + $rootDestination = Join-Path $baseFolder 'custom/perProjectFile.algo' + $projectDestination = Join-Path $baseFolder 'ProjectOne/custom/perProjectFile.algo' $filesToInclude.destinationFullPath | Should -Contain $rootDestination $filesToInclude.destinationFullPath | Should -Contain $projectDestination @@ -2274,7 +3208,6 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $baseFolder = 'baseFolder' $projects = @('ProjectA') $filesWithoutOriginal, $excludesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects @@ -2301,6 +3234,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) $excludesWithoutOriginal | Should -BeNullOrEmpty + $excludesWithOriginal | Should -BeNullOrEmpty } finally { @@ -2323,7 +3257,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # test.txt should not be in filesToInclude $includedTestTxt = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } @@ -2344,7 +3278,7 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # All txt files should be included $filesToInclude | Should -Not -BeNullOrEmpty @@ -2356,6 +3290,28 @@ Describe "GetFilesToUpdate (general files to update logic)" { $excludedNonExistent | Should -BeNullOrEmpty } + It 'GetFilesToUpdate excludes files with different destinations that match both include and exclude patterns' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.txt"; destinationName = "test.renamed.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # test.txt should not be in filesToInclude + $filesToInclude | Should -BeNullOrEmpty + + # test.txt should be in filesToExclude two times with different destinations + $testTxtFiles = $filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } + $testTxtFiles.Count | Should -Be 2 + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.txt') + $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test.renamed.txt') + } + It 'GetFilesToUpdate handles overlapping include patterns with different destinations' { $settings = @{ type = "NotPTE" @@ -2369,13 +3325,368 @@ Describe "GetFilesToUpdate (general files to update logic)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder # Should have two entries for test.txt with different destinations $testTxtFiles = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } $testTxtFiles.Count | Should -Be 2 - $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'folder1/test.txt') - $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'folder2/test.txt') + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'folder1/test.txt') + $testTxtFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'folder2/test.txt') + } + + It 'GetFilesToUpdate filesToInclude keeps the first entry when two entries collide on the same destination' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1"; destinationName = "conflict.txt" }, @{ filter = "test.txt"; destinationName = "conflict.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + # Only one entry should be resolved for the colliding destination + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder "conflict.txt") }) + $conflict.Count | Should -Be 1 + + # The first-listed entry should win over the later entry for the same destination + $conflict[0].sourceFullPath | Should -Be $testPSFile + } + + It 'GetFilesToUpdate keeps case-distinct destination entries on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeTrue + } + + It 'GetFilesToUpdate excludes only the exact-case source path on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' + $lowerCaseFolder = Join-Path $templateFolder 'casefolder' + $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' + $lowerCaseFile = Join-Path $lowerCaseFolder 'script.ps1' + New-Item -ItemType Directory -Path $upperCaseFolder -Force | Out-Null + New-Item -ItemType Directory -Path $lowerCaseFolder -Force | Out-Null + Set-Content -Path $upperCaseFile -Value '# upper case folder' + Set-Content -Path $lowerCaseFile -Value '# lower case folder' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ sourceFolder = 'CaseFolder'; filter = 'script.ps1' } + @{ sourceFolder = 'casefolder'; filter = 'script.ps1' } + ) + filesToExclude = @(@{ sourceFolder = 'casefolder'; filter = 'script.ps1' }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.Count | Should -Be 1 + $filesToInclude[0].sourceFullPath | Should -BeExactly $upperCaseFile + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].sourceFullPath | Should -BeExactly $lowerCaseFile + } + finally { + Remove-Item -Path $upperCaseFolder -Recurse -Force + Remove-Item -Path $lowerCaseFolder -Recurse -Force + } + } + + It 'GetFilesToUpdate excludes only the exact-case unused file on case-sensitive platforms' -Skip:(-not $script:isLinuxPlatform) { + $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' + $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' + Set-Content -Path $upperCaseFile -Value '# upper case file' + Set-Content -Path $lowerCaseFile -Value '# lower case file' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @('unusedfile.ps1') + customALGoFiles = @{ + filesToInclude = @(@{ filter = '*.ps1' }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.sourceFullPath -ccontains $upperCaseFile | Should -BeTrue + $filesToInclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeFalse + $filesToExclude.sourceFullPath -ccontains $upperCaseFile | Should -BeFalse + $filesToExclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeTrue + } + finally { + Remove-Item -Path $upperCaseFile -Force + Remove-Item -Path $lowerCaseFile -Force + } + } + + It 'GetFilesToUpdate removes case-distinct destination entries on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeFalse + } + + It 'GetFilesToUpdate excludes any case source path on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + $upperCaseFolder = Join-Path $templateFolder 'CaseFolder' + $upperCaseFile = Join-Path $upperCaseFolder 'script.ps1' + New-Item -ItemType Directory -Path $upperCaseFolder -Force | Out-Null + Set-Content -Path $upperCaseFile -Value '# upper case folder' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @( + @{ sourceFolder = 'CaseFolder'; filter = 'script.ps1' } + @{ sourceFolder = 'casefolder'; filter = 'script.ps1' } + ) + filesToExclude = @(@{ sourceFolder = 'casefolder'; filter = 'script.ps1' }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.Count | Should -Be 0 + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].sourceFullPath | Should -BeExactly $upperCaseFile + } + finally { + Remove-Item -Path $upperCaseFolder -Recurse -Force + } + } + + It 'GetFilesToUpdate excludes any case unused file on case-insensitive platforms' -Skip:$script:isLinuxPlatform { + $upperCaseFile = Join-Path $templateFolder 'UnusedFile.ps1' + $lowerCaseFile = Join-Path $templateFolder 'unusedfile.ps1' + Set-Content -Path $upperCaseFile -Value '# upper case file' + + try { + $settings = @{ + type = 'NotPTE' + unusedALGoSystemFiles = @('unusedfile.ps1') + customALGoFiles = @{ + filesToInclude = @(@{ filter = '*.ps1' }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $filesToInclude.sourceFullPath -ccontains $upperCaseFile | Should -BeFalse + $filesToInclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeFalse + $filesToExclude.sourceFullPath -ccontains $upperCaseFile | Should -BeTrue + $filesToExclude.sourceFullPath -ccontains $lowerCaseFile | Should -BeFalse + } + finally { + Remove-Item -Path $upperCaseFile -Force + } + } + + It 'GetFilesToUpdate filesToInclude includes original template files missing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.original.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.original.txt of original template should be in filesToInclude + $testOriginalTemplateTxtFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -eq $testOriginalTemplateTxtFile }) + $testOriginalTemplateTxtFiles | Should -Not -BeNullOrEmpty + $testOriginalTemplateTxtFiles.Count | Should -Be 1 + $testOriginalTemplateTxtFiles[0].sourceFullPath | Should -Be $testOriginalTemplateTxtFile + $testOriginalTemplateTxtFiles[0].originalSourceFullPath | Should -Be $null + $testOriginalTemplateTxtFiles[0].destinationFullPath | Should -Be ( Join-Path $baseFolder "test.original.txt" ) + } + + It 'GetFilesToUpdate filesToExclude excludes original template files missing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.original.txt" }) + filesToExclude = @(@{ filter = "test.original.txt" }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.original.txt of original template should be in filesToExclude + $testOriginalTemplateTxtFiles = @($filesToExclude | Where-Object { $_.sourceFullPath -eq $testOriginalTemplateTxtFile }) + $testOriginalTemplateTxtFiles | Should -Not -BeNullOrEmpty + $testOriginalTemplateTxtFiles.Count | Should -Be 1 + $testOriginalTemplateTxtFiles[0].sourceFullPath | Should -Be $testOriginalTemplateTxtFile + $testOriginalTemplateTxtFiles[0].originalSourceFullPath | Should -Be $null + $testOriginalTemplateTxtFiles[0].destinationFullPath | Should -Be ( Join-Path $baseFolder "test.original.txt" ) + } + + It 'GetFilesToUpdate filesToInclude not including original template files existing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.txt of template should be in filesToInclude + $testTxtFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") }) + $testTxtFiles | Should -Not -BeNullOrEmpty + $testTxtFiles.Count | Should -Be 1 + $testTxtFiles[0].sourceFullPath | Should -Be (Join-Path $templateFolder "test.txt") + $testTxtFiles[0].originalSourceFullPath | Should -Be ( Join-Path $originalTemplateFolder "test.txt" ) + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") + + # test.txt of original template should not be in filesToInclude + $filesToInclude.SourceFullPath | Should -Not -Contain ( Join-Path $originalTemplateFolder "test.txt" ) + } + + It 'GetFilesToUpdate filesToExclude not excluding original template files existing in template' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder + + # test.txt of template should be in filesToExclude + $testTxtFiles = @($filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") }) + $testTxtFiles | Should -Not -BeNullOrEmpty + $testTxtFiles.Count | Should -Be 1 + $testTxtFiles[0].sourceFullPath | Should -Be (Join-Path $templateFolder "test.txt") + $testTxtFiles[0].originalSourceFullPath | Should -Be ( Join-Path $originalTemplateFolder "test.txt" ) + $testTxtFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder "test.txt") + + # test.txt of original template should not be in filesToExclude + $filesToExclude.SourceFullPath | Should -Not -Contain ( Join-Path $originalTemplateFolder "test.txt" ) + } +} + +Describe "ReadSettingsWithCurrentCustomTemplateRepoSettings" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + } + + It 'Uses current template settings and restores an existing snapshot' { + $templateFolder = Join-Path $TestDrive "templateWithCurrentSettings" + $baseFolder = Join-Path $TestDrive "baseWithExistingSnapshot" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + $templateSettingsContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' + Set-Content -LiteralPath $templateSettingsFile -Value $templateSettingsContent -Encoding UTF8 + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + $snapshotHash = (Get-FileHash -LiteralPath $snapshotFile).Hash + + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude.Count | Should -Be 1 + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" + (Get-FileHash -LiteralPath $snapshotFile).Hash | Should -Be $snapshotHash + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent + } + + It 'Removes a temporary snapshot when none existed before reading settings' { + $templateFolder = Join-Path $TestDrive "templateWithoutSnapshot" + $baseFolder = Join-Path $TestDrive "baseWithoutSnapshot" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + Test-Path -LiteralPath $snapshotFile | Should -Be $false + + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" + Test-Path -LiteralPath $snapshotFile | Should -Be $false + } + + It 'Does not change an existing snapshot when the template has no settings file' { + $templateFolder = Join-Path $TestDrive "templateWithoutSettings" + $baseFolder = Join-Path $TestDrive "baseWithUnchangedSnapshot" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"existing.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + $snapshotHash = (Get-FileHash -LiteralPath $snapshotFile).Hash + + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "existing.txt" + (Get-FileHash -LiteralPath $snapshotFile).Hash | Should -Be $snapshotHash + } + + It 'Restores an existing snapshot when reading refreshed settings fails' { + $templateFolder = Join-Path $TestDrive "templateWithInvalidSettings" + $baseFolder = Join-Path $TestDrive "baseWithSnapshotAfterFailure" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{ invalid json' -Encoding UTF8 + + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -templateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent } } @@ -2390,6 +3701,16 @@ Describe "GetFilesToUpdate (real template)" { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'realAppSourceAppTemplateFolder', Justification = 'False positive.')] $realAppSourceAppTemplateFolder = Join-Path $PSScriptRoot "../Templates/AppSource App" -Resolve + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'baseFolder', Justification = 'False positive.')] + $baseFolder = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'baseFolder')) + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'powerPlatformFiles', Justification = 'False positive.')] + $powerPlatformFiles = @( + ".github/workflows/_BuildPowerPlatformSolution.yaml", + ".github/workflows/PullPowerPlatformChanges.yaml", + ".github/workflows/PushPowerPlatformChanges.yaml" + ) } It 'Return the correct files to exclude when type is PTE and powerPlatformSolutionFolder is not empty' { @@ -2403,18 +3724,80 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 25 - $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[0]) + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[1]) + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[2]) - # No files to remove + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty } + It 'GetFilesToUpdate defaults filesToInclude takes precedence over repository settings for the same destination' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + # Redirect a different template file onto the destination of the default AL-Go-Settings.json entry + filesToInclude = @(@{ filter = "Test Next Major.settings.json"; sourceFolder = ".github"; destinationFolder = ".github"; destinationName = "$RepoSettingsFileName" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder + + $repoSettingsDestination = Join-Path $baseFolder (Join-Path '.github' $RepoSettingsFileName) + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq $repoSettingsDestination }) + $conflict.Count | Should -Be 1 + + # The default entry should win over the repository settings entry for the same destination + $conflict[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder (Join-Path '.github' $RepoSettingsFileName)) + } + + It 'GetFilesToUpdate defaults filesToExclude combined with repository settings filesToExclude for non-colliding files' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = "_BuildALGoProject.yaml"; sourceFolder = ".github/workflows" }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder + + # The default exclude entries (PowerPlatform files) and the repository settings' own exclude entry are both applied + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $powerPlatformFiles[0]) + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildALGoProject.yaml") + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildALGoProject.yaml") + } + + It 'GetFilesToUpdate defaults filesToExclude and repository settings filesToExclude for the same source file are both applied without duplicates' { + # The repository settings entry excludes the exact same file that the default PowerPlatform exclude entries + # already exclude (since powerPlatformSolutionFolder is empty). This should not error out or produce a + # duplicate entry: the file should end up excluded exactly once. + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @(@{ filter = [System.IO.Path]::GetFileName($powerPlatformFiles[0]); sourceFolder = [System.IO.Path]::GetDirectoryName($powerPlatformFiles[0]).Replace('\', '/') }) + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder + + $ppFileSourcePath = Join-Path $realPTETemplateFolder $powerPlatformFiles[0] + @($filesToExclude | Where-Object { $_.sourceFullPath -eq $ppFileSourcePath }).Count | Should -Be 1 + $filesToInclude.sourceFullPath | Should -Not -Contain $ppFileSourcePath + } + It 'Return PP files in filesToExclude when type is PTE but powerPlatformSolutionFolder is empty' { $settings = @{ type = "PTE" @@ -2426,30 +3809,26 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 22 $filesToInclude | ForEach-Object { - $_.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $_.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $_.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") + $fileToInclude = $_ + $powerPlatformFiles | ForEach-Object { + $fileToInclude.sourceFullPath | Should -Not -Be (Join-Path $realPTETemplateFolder $_) + } } # All PP files to remove $filesToExclude | Should -Not -BeNullOrEmpty - $filesToExclude.Count | Should -Be 3 - - $filesToExclude[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/_BuildPowerPlatformSolution.yaml") - - $filesToExclude[1].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") - $filesToExclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/PushPowerPlatformChanges.yaml") - - $filesToExclude[2].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $filesToExclude[2].destinationFullPath | Should -Be (Join-Path 'baseFolder' ".github/workflows/PullPowerPlatformChanges.yaml") + $filesToExclude.Count | Should -Be $powerPlatformFiles.Count + $powerPlatformFiles | ForEach-Object { + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $_) + $filesToExclude.destinationFullPath | Should -Contain (Join-Path $baseFolder $_) + } } It 'Return the correct files when unusedALGoSystemFiles is specified' { @@ -2463,7 +3842,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 24 @@ -2472,7 +3851,7 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude | Should -Not -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 $filesToExclude[0].sourceFullPath | Should -Be (Join-Path $realPTETemplateFolder ".github/Test Next Major.settings.json") - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/Test Next Major.settings.json') + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/Test Next Major.settings.json') } It 'Return the correct files when unusedALGoSystemFiles is specified and no PP solution is present' { @@ -2486,7 +3865,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder $filesToInclude | Should -Not -BeNullOrEmpty $filesToInclude.Count | Should -Be 21 @@ -2496,9 +3875,9 @@ Describe "GetFilesToUpdate (real template)" { $filesToExclude.Count | Should -Be 4 $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/Test Next Major.settings.json") - $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/_BuildPowerPlatformSolution.yaml") - $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PullPowerPlatformChanges.yaml") - $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder ".github/workflows/PushPowerPlatformChanges.yaml") + $powerPlatformFiles | ForEach-Object { + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $realPTETemplateFolder $_) + } } It 'Returns the custom template settings files when there is a custom template' { @@ -2514,7 +3893,7 @@ Describe "GetFilesToUpdate (real template)" { $customTemplateFolder = $realPTETemplateFolder $originalTemplateFolder = $realAppSourceAppTemplateFolder - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template $filesToInclude | Should -Not -BeNullOrEmpty @@ -2525,11 +3904,11 @@ Describe "GetFilesToUpdate (real template)" { $repoSettingsFiles.Count | Should -Be 2 $repoSettingsFiles[0].originalSourceFullPath | Should -Be (Join-Path $originalTemplateFolder ".github/AL-Go-Settings.json") - $repoSettingsFiles[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-Settings.json') + $repoSettingsFiles[0].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/AL-Go-Settings.json') $repoSettingsFiles[0].type | Should -Be 'settings' $repoSettingsFiles[1].originalSourceFullPath | Should -Be $null # Because origin is 'custom template', originalSourceFullPath should be $null - $repoSettingsFiles[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-TemplateRepoSettings.doNotEdit.json') + $repoSettingsFiles[1].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/AL-Go-TemplateRepoSettings.doNotEdit.json') $repoSettingsFiles[1].type | Should -Be '' # Check project settings files @@ -2539,17 +3918,74 @@ Describe "GetFilesToUpdate (real template)" { $projectSettingsFilesFromCustomTemplate.Count | Should -Be 2 $projectSettingsFilesFromCustomTemplate[0].originalSourceFullPath | Should -Be (Join-Path $originalTemplateFolder ".AL-Go/settings.json") - $projectSettingsFilesFromCustomTemplate[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.AL-Go/settings.json') + $projectSettingsFilesFromCustomTemplate[0].destinationFullPath | Should -Be (Join-Path $baseFolder '.AL-Go/settings.json') $projectSettingsFilesFromCustomTemplate[0].type | Should -Be 'settings' $projectSettingsFilesFromCustomTemplate[1].originalSourceFullPath | Should -Be $null # Because origin is 'custom template', originalSourceFullPath should be $null - $projectSettingsFilesFromCustomTemplate[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' '.github/AL-Go-TemplateProjectSettings.doNotEdit.json') + $projectSettingsFilesFromCustomTemplate[1].destinationFullPath | Should -Be (Join-Path $baseFolder '.github/AL-Go-TemplateProjectSettings.doNotEdit.json') $projectSettingsFilesFromCustomTemplate[1].type | Should -Be '' - # No files to exclude + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty + } + + It 'Returns the original template PP files in filesToInclude when there is a custom template without them and powerPlatformSolutionFolder is not empty' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "PowerPlatformSolution" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out + $customTemplateFolder = $realAppSourceAppTemplateFolder + $originalTemplateFolder = $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + + $filesToInclude | Should -Not -BeNullOrEmpty + $powerPlatformFiles | ForEach-Object { + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $customTemplateFolder $_) + $filesToInclude.sourceFullPath | Should -Contain (Join-Path $originalTemplateFolder $_) + } + + # No files to exclude or remove $filesToExclude | Should -BeNullOrEmpty } + It 'Returns the original template PP files in filesToExclude when there is a custom template without them and powerPlatformSolutionFolder is empty' { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = "" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + # AppSource App is used as custom template because it has no PP workflows, simulating a custom PTE fork that stripped them out + $customTemplateFolder = $realAppSourceAppTemplateFolder + $originalTemplateFolder = $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -projects @('.') -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder # Indicate custom template + + $filesToInclude | Should -Not -BeNullOrEmpty + $powerPlatformFiles | ForEach-Object { + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $customTemplateFolder $_) + $filesToInclude.sourceFullPath | Should -Not -Contain (Join-Path $originalTemplateFolder $_) + } + + $filesToExclude | Should -Not -BeNullOrEmpty + $powerPlatformFiles | ForEach-Object { + $filesToExclude.sourceFullPath | Should -Not -Contain (Join-Path $customTemplateFolder $_) + $filesToExclude.sourceFullPath | Should -Contain (Join-Path $originalTemplateFolder $_) + } + + # No files to remove + } + It 'GetFilesToUpdate handles AppSource template type correctly' { $settings = @{ type = "AppSource App" @@ -2561,7 +3997,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realAppSourceAppTemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realAppSourceAppTemplateFolder # PowerPlatform files should be excluded for AppSource App too (same as PTE) $filesToInclude | Should -Not -BeNullOrEmpty @@ -2585,7 +4021,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder # No additional files should be excluded due to unusedALGoSystemFiles $ppExcludes = $filesToExclude | Where-Object { $_.sourceFullPath -like "*_BuildPowerPlatformSolution.yaml" -or $_.sourceFullPath -like "*PullPowerPlatformChanges.yaml" -or $_.sourceFullPath -like "*PushPowerPlatformChanges.yaml" } @@ -2603,7 +4039,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects @('Project1') + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder -projects @('Project1') # Check that settings files have type = 'settings' $repoSettingsFiles = @($filesToInclude | Where-Object { $_.sourceFullPath -like "*$RepoSettingsFileName" -and $_.destinationFullPath -like "*.github*$RepoSettingsFileName" }) @@ -2627,7 +4063,7 @@ Describe "GetFilesToUpdate (real template)" { } $projects = @('ProjectA', 'ProjectB', 'ProjectC') - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder -projects $projects + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder -projects $projects # Each project should have its own settings file $projectASettings = $filesToInclude | Where-Object { $_.destinationFullPath -like "*ProjectA*.AL-Go*" } @@ -2650,7 +4086,7 @@ Describe "GetFilesToUpdate (real template)" { } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $realPTETemplateFolder + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $realPTETemplateFolder # Test Next Major.settings.json should be excluded $testNextMajor = $filesToInclude | Where-Object { $_.sourceFullPath -like "*Test Next Major.settings.json" } diff --git a/e2eTests/scenarios/CustomTemplate/runtest.ps1 b/e2eTests/scenarios/CustomTemplate/runtest.ps1 index 5cdef971b3..93132e89e3 100644 --- a/e2eTests/scenarios/CustomTemplate/runtest.ps1 +++ b/e2eTests/scenarios/CustomTemplate/runtest.ps1 @@ -31,8 +31,12 @@ Write-Host -ForegroundColor Yellow @' # - Create a new repository based on the PTE template with 1 app, using compilerfolder and donotpublishapps (this will be the "final" template repository) # - Run Update AL-Go System Files in final repo (using custom template repository as template) # - Run Update AL-Go System files in custom template repository +# - Validate that custom AL-Go files are applied in custom template repository # - Validate that custom job is present in custom template repository # - Run Update AL-Go System files in final repo +# - Validate that custom AL-Go files of template repository are applied in final repository +# - Run Update AL-Go System files in final repo +# - Validate that custom AL-Go files of template repository and final repository are applied in final repository # - Validate that custom job is present in final repo # '@ @@ -55,6 +59,8 @@ $template = "https://github.com/$pteTemplate" # Login SetTokenAndRepository -github:$github -githubOwner $githubOwner -appId $e2eAppId -appKey $e2eAppKey -repository $repository +#region create repositories + # Create template repository CreateAlGoRepository ` -github:$github ` @@ -64,6 +70,9 @@ CreateAlGoRepository ` -branch $branch $templateRepoPath = (Get-Location).Path +# Stop all currently running workflows on template repository +CancelAllWorkflows -repository $templateRepository + Set-Location $prevLocation $appName = 'MyApp' @@ -76,15 +85,26 @@ CreateAlGoRepository ` -template $template ` -repository $repository ` -branch $branch ` + -addRepoSettings @{ "useCompilerFolder" = $true; "doNotPublishApps" = $true } ` -contentScript { Param([string] $path) $null = CreateNewAppInFolder -folder $path -name $appName -publisher $publisherName } $finalRepoPath = (Get-Location).Path +# Stop all currently running workflows on final repository +CancelAllWorkflows -repository $repository + # Update AL-Go System Files to use template repository RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null +# Stop all currently running workflows on final repository +CancelAllWorkflows -repository $repository + +#endregion + +#region setup template repository customizations + Set-Location $templateRepoPath Pull @@ -154,6 +174,10 @@ on: branches: - main +defaults: + run: + shell: powershell + jobs: CustomJob: runs-on: [ windows-latest ] @@ -166,23 +190,97 @@ jobs: "@ Set-Content -Path $customWorkflowFile -Value $customWorkflowContent +$finalRepoCustomWorkflowContent = $customWorkflowContent if($linux) { - # Modify workflow to run on ubuntu-latest if the test is running on linux. AL-Go will not modify workflow files based on platform, so we need to do it here to ensure the test works correctly. - $customWorkflowContent = $customWorkflowContent -replace 'windows-latest', 'ubuntu-latest' + $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'windows-latest', 'ubuntu-latest' + $finalRepoCustomWorkflowContent = $finalRepoCustomWorkflowContent -replace 'shell: powershell', 'shell: pwsh' } -# Add another custom file in the template repository (to be ignored unless specifically added via the settings) -$customFileName = 'CustomTemplateFile.txt' -$customFile = Join-Path $templateRepoPath $customFileName -$customFileContent = "This is a custom file in the template repository." -Set-Content -Path $customFile -Value $customFileContent +# Add custom files in the template repository +$defaultCustomFileName = 'CustomTemplateFile.Default.txt' +$defaultCustomFile = Join-Path $templateRepoPath $defaultCustomFileName +$defaultCustomFileContent = "This is a default custom file in the template repository." +Set-Content -Path $defaultCustomFile -Value $defaultCustomFileContent + +$optionalCustomFileName = 'CustomTemplateFile.Optional.txt' +$optionalCustomFile = Join-Path $templateRepoPath $optionalCustomFileName +$optionalCustomFileContent = "This is an optional custom file in the template repository." +Set-Content -Path $optionalCustomFile -Value $optionalCustomFileContent + +# Remove workflow files from template repository +$excludedWorkflowFileName = 'DeployReferenceDocumentation.yaml' +$excludedWorkflowFileRelativePath = Join-Path '.github/workflows' $excludedWorkflowFileName +$excludedWorkflowFile = Join-Path $templateRepoPath $excludedWorkflowFileRelativePath +Remove-Item -Path $excludedWorkflowFile -Force | Out-Null + +$missingWorkflowFileName = 'Troubleshooting.yaml' +$missingWorkflowFileRelativePath = Join-Path '.github/workflows' $missingWorkflowFileName +$missingWorkflowFile = Join-Path $templateRepoPath $missingWorkflowFileRelativePath +Remove-Item -Path $missingWorkflowFile -Force | Out-Null + +# Add customALGoFiles settings to the template repository +$templateRepoSettingsFile = Join-Path $templateRepoPath $RepoSettingsFile +$null = Add-PropertiesToJsonFile -path $templateRepoSettingsFile -properties @{ + "customALGoFiles" = @{ + "filesToInclude" = @( @{ "filter" = $defaultCustomFileName } ) + "filesToExclude" = @( @{ "sourceFolder" = ".github/workflows"; "filter" = $excludedWorkflowFileName } ) + } +} # Push -CommitAndPush -commitMessage 'Add template customizations' +CommitAndPush -commitMessage 'Add template customizations [skip ci]' + +#endregion + +#region update template repository with template repository customizations + +# Update AL-Go System Files for template repository to update customizations from template repository +RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $template -ghTokenWorkflow $algoauthapp -repository $templateRepository -branch $branch | Out-Null -# Do not run workflows on template repository +# Stop all currently running workflows on template repository CancelAllWorkflows -repository $templateRepository +# Pull changes +Pull + +# Check that custom workflow file is present +(Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $customWorkflowContent.Replace("`r", "").TrimEnd("`n") + +# Check that default custom file is present +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should -Be $defaultCustomFileContent.Replace("`r", "").TrimEnd("`n") +# Check that optional custom file is present +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") + +# Check that excluded workflow file is NOT present (in template's filesToExclude) +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist +# Check that missing workflow file is present (in default filesToInclude) +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist + +# Remove missing workflow files from template repository again +Remove-Item -Path $missingWorkflowFile -Force | Out-Null + +# Push +CommitAndPush -commitMessage 'Restore template customizations [skip ci]' + +#endregion + +#region validate template repository CI/CD workflow + +# Run CICD +$run = RunCICD -repository $templateRepository -branch $branch -wait + +# Check Custom Jobs +Test-LogContainsFromRun -repository $templateRepository -runid $run.id -jobName 'CustomJob-TemplateInit' -stepName 'Init' -expectedText 'CustomJob-TemplateInit was here!' +Test-LogContainsFromRun -repository $templateRepository -runid $run.id -jobName 'CustomJob-TemplateDeploy' -stepName 'Deploy' -expectedText 'CustomJob-TemplateDeploy was here!' +{ Test-LogContainsFromRun -repository $templateRepository -runid $run.id -jobName 'JustSomeTemplateJob' -stepName 'JustSomeTemplateStep' -expectedText 'JustSomeTemplateJob was here!' } | Should -Throw + +#endregion + +#region setup final repository customizations + # Add local customizations to the final repository Set-Location $finalRepoPath Pull @@ -245,14 +343,42 @@ $cicdYaml.AddCustomJobsToYaml($customJobs, [CustomizationOrigin]::FinalRepositor # save $cicdYaml.Save($cicdWorkflow) +# Remove workflow files from final repository +Remove-Item -Path (Join-Path (Get-Location) $missingWorkflowFileRelativePath) -Force | Out-Null + +# Check that custom workflow file is NOT present +(Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Not -Exist + +# Check that default custom file is NOT present in final repository +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist +# Check that optional custom file is NOT present in final repository +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist + +# Check that excluded workflow file is present in final repository +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Exist +# Check that missing workflow file is NOT present in final repository +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Not -Exist + +# Create a stale snapshot of the template repository settings file in the final repository, +# to simulate a scenario where the final repository has an outdated snapshot of the template repository's settings +Copy-Item -Path $templateRepoSettingsFile -Destination $CustomTemplateRepoSettingsFile -Force +$null = Add-PropertiesToJsonFile -path $CustomTemplateRepoSettingsFile -properties @{ + "customALGoFiles" = @{ + "filesToExclude" = @( @{ "sourceFolder" = ".github/workflows"; "filter" = $missingWorkflowFileName } ) + } +} # Push -CommitAndPush -commitMessage 'Add final repo customizations' +CommitAndPush -commitMessage 'Add final repo customizations [skip ci]' + +#endregion -# Update AL-Go System Files to uptake UseProjectDependencies setting +#region update final repository with template repository customizations + +# Update AL-Go System Files for the final repository to uptake customizations from template repository RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null -# Stop all currently running workflows and run a new CI/CD workflow +# Stop all currently running workflows on final repository CancelAllWorkflows -repository $repository # Pull changes @@ -260,42 +386,87 @@ Pull (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | Should -Exist (Join-Path (Get-Location) $CustomTemplateProjectSettingsFile) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $CustomTemplateRepoSettingsFile) | Should -Be (Get-ContentLF -Path $templateRepoSettingsFile) # Check that custom workflow file is present (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $customWorkflowContent.Replace("`r", "").TrimEnd("`n") +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") + +# Check that default custom file is present (in template's filesToInclude) +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $defaultCustomFileName) | Should -Be $defaultCustomFileContent.Replace("`r", "").TrimEnd("`n") +# Check that optional custom file is NOT present (not in default or template's filesToInclude) +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Not -Exist -# Check that custom file is NOT present -(Join-Path (Get-Location) $customFileName) | Should -Not -Exist # Custom file should not be copied by default +# Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist +# Check that missing workflow file is present (in default filesToInclude, propagated from PTE template). +# This proves the stale snapshot exclusion seeded during final repository setup was replaced. +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist -# Add custom file to be copied via settings -$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ "customALGoFiles" = @{ "filesToInclude" = @( @{ "filter" = $customFileName } ) } } +#endregion + +#region setup final repository customizations for next update run + +# Add customALGoFiles settings to the final repository +$null = Add-PropertiesToJsonFile -path '.github/AL-Go-Settings.json' -properties @{ + "customALGoFiles" = @{ + "filesToInclude" = @( @{ "filter" = $optionalCustomFileName } ) + "filesToExclude" = @( @{ "filter" = $defaultCustomFileName } ) + } +} # Push -CommitAndPush -commitMessage 'Add custom file to be updated when updating AL-Go system files [skip ci]' +CommitAndPush -commitMessage 'Add custom files to be updated when updating AL-Go system files [skip ci]' -# Update AL-Go System Files to uptake custom file +#endregion + +#region update final repository with template and final repository customizations + +# Update AL-Go System Files for final repository to uptake customizations from final repository RunUpdateAlGoSystemFiles -directCommit -wait -templateUrl $templateRepository -ghTokenWorkflow $algoauthapp -repository $repository -branch $branch | Out-Null +# Stop all currently running workflows on final repository +CancelAllWorkflows -repository $repository + # Pull changes Pull -# Check that custom file is now present -(Join-Path (Get-Location) $customFileName) | Should -Exist -Get-ContentLF -Path (Join-Path (Get-Location) $customFileName)| Should -Be $customFileContent.Replace("`r", "").TrimEnd("`n") +# Check that custom workflow file is present +(Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $customWorkflowfileRelativePath) | Should -Be $finalRepoCustomWorkflowContent.Replace("`r", "").TrimEnd("`n") + + # Check that default custom file is NOT present (in repo's filesToExclude and template's filesToInclude) +(Join-Path (Get-Location) $defaultCustomFileName) | Should -Not -Exist +# Check that optional custom file is present (in repo's filesToInclude) +(Join-Path (Get-Location) $optionalCustomFileName) | Should -Exist +Get-ContentLF -Path (Join-Path (Get-Location) $optionalCustomFileName) | Should -Be $optionalCustomFileContent.Replace("`r", "").TrimEnd("`n") + +# Check that excluded workflow file is NOT present (in default filesToInclude and template's filesToExclude) +(Join-Path (Get-Location) $excludedWorkflowFileRelativePath) | Should -Not -Exist +# Check that missing workflow file is present (in default filesToInclude, propagated from PTE template) +(Join-Path (Get-Location) $missingWorkflowFileRelativePath) | Should -Exist + +#endregion + +#region validate final repository CI/CD workflow # Run CICD $run = RunCICD -repository $repository -branch $branch -wait # Check Custom Jobs -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-TemplateInit' -stepName 'Init' -expectedText 'CustomJob-TemplateInit was here!' -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-TemplateDeploy' -stepName 'Deploy' -expectedText 'CustomJob-TemplateDeploy was here!' -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-PreDeploy' -stepName 'PreDeploy' -expectedText 'CustomJob-PreDeploy was here!' -Test-LogContainsFromRun -runid $run.id -jobName 'CustomJob-PostDeploy' -stepName 'PostDeploy' -expectedText 'CustomJob-PostDeploy was here!' -{ Test-LogContainsFromRun -runid $run.id -jobName 'JustSomeJob' -stepName 'JustSomeStep' -expectedText 'JustSomeJob was here!' } | Should -Throw -{ Test-LogContainsFromRun -runid $run.id -jobName 'JustSomeTemplateJob' -stepName 'JustSomeTemplateStep' -expectedText 'JustSomeTemplateJob was here!' } | Should -Throw +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-TemplateInit' -stepName 'Init' -expectedText 'CustomJob-TemplateInit was here!' +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-TemplateDeploy' -stepName 'Deploy' -expectedText 'CustomJob-TemplateDeploy was here!' +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-PreDeploy' -stepName 'PreDeploy' -expectedText 'CustomJob-PreDeploy was here!' +Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'CustomJob-PostDeploy' -stepName 'PostDeploy' -expectedText 'CustomJob-PostDeploy was here!' +{ Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'JustSomeJob' -stepName 'JustSomeStep' -expectedText 'JustSomeJob was here!' } | Should -Throw +{ Test-LogContainsFromRun -repository $repository -runid $run.id -jobName 'JustSomeTemplateJob' -stepName 'JustSomeTemplateStep' -expectedText 'JustSomeTemplateJob was here!' } | Should -Throw + +#endregion Set-Location $prevLocation +RefreshToken -repository $repository RemoveRepository -repository $repository -path $finalRepoPath +RefreshToken -repository $templateRepository RemoveRepository -repository $templateRepository -path $templateRepoPath