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 a0791bb8ae..2e05d6c43d 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.HelperFunctions.ps1 @@ -510,7 +510,8 @@ function GetWorkflowContentWithChangesFromSettings { Param( [string] $srcFile, [hashtable] $repoSettings, - [int] $depth + [int] $depth, + [string] $customTemplateFolder = '' ) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($srcFile) @@ -532,7 +533,12 @@ function GetWorkflowContentWithChangesFromSettings { } # Re-read settings and this time include workflow specific settings - $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' | ConvertTo-HashTable -recurse + if ($customTemplateFolder) { + $repoSettings = ReadSettingsWithCurrentCustomTemplateRepoSettings -customTemplateFolder $customTemplateFolder -workflowName $workflowName + } + else { + $repoSettings = ReadSettings -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } # Old Schedule key is deprecated, but still supported $oldWorkflowScheduleKey = "$($baseName)Schedule" @@ -710,7 +716,7 @@ function GetModifiedSettingsContent { $srcSettings = Get-ContentLF -Path $srcSettingsFile | ConvertFrom-Json $dstSettings = $null - if(Test-Path -Path $dstSettingsFile -PathType Leaf) { + if(Test-Path -LiteralPath $dstSettingsFile -PathType Leaf) { $dstSettings = Get-ContentLF -Path $dstSettingsFile | ConvertFrom-Json } @@ -801,6 +807,230 @@ 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 +Resolves a path to an absolute, lexically-canonicalized path (resolving ".." and "." segments). +.DESCRIPTION +[System.IO.Path]::GetFullPath() resolves a relative path against [System.Environment]::CurrentDirectory, +which Set-Location does not reliably keep in sync with PowerShell's own current location - so a bare +GetFullPath() call can silently resolve against a stale directory after Set-Location. This function instead +joins a relative $Path against PowerShell's actual current location before canonicalizing, so behavior is +correct regardless of that .NET/PowerShell CWD desync. +.PARAMETER Path +The literal path to resolve. If not rooted, it is considered relative to the current location. +.PARAMETER AsDirectory +If set, ensures the returned path ends with a directory separator (e.g. for safe prefix/StartsWith checks). +.OUTPUTS +The absolute, lexically-canonicalized path. +#> +function Resolve-PathLexically { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [switch] $AsDirectory + ) + + if (-not [System.IO.Path]::IsPathRooted($Path)) { + $Path = Join-Path (Get-Location).Path $Path # resolve relative to the current location + } + + $Path = [System.IO.Path]::GetFullPath($Path) # canonicalize the path to an absolute path + + if ($AsDirectory) { + $Path = Join-Path $Path '' # ensure the path ends with a directory separator + } + + return $Path +} + +<# +.SYNOPSIS +Checks whether a path is lexically contained within a root folder, resolving ".." and "." segments. +.DESCRIPTION +Verifies that $Path is located under $RootFolder purely by string/segment resolution (via Resolve-PathLexically) +.PARAMETER Path +The literal path to check. If not rooted, it is considered relative to the root folder. +.PARAMETER RootFolder +The literal root folder that $Path must be contained within. Defaults to the current location if not specified. +.OUTPUTS +$true if the path is lexically contained within the root folder, otherwise $false. +#> +function Test-PathLexicallyContained { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [string] $RootFolder = (Get-Location).Path + ) + + $pathComparison = GetPathStringComparison + + $RootFolder = Resolve-PathLexically -Path $RootFolder -AsDirectory + + if (-not [System.IO.Path]::IsPathRooted($Path)) { + $Path = Join-Path $RootFolder $Path # resolve relative to the root folder + } + # AsDirectory here (not just for $RootFolder) makes $Path itself count as contained, not just its descendants + $Path = Resolve-PathLexically -Path $Path -AsDirectory + + return $Path.StartsWith($RootFolder, $pathComparison) +} + +<# +.SYNOPSIS +Resolves a path to its final physical location, following any symbolic links/junctions along the way. +.DESCRIPTION +Walks $Path segment by segment starting from the most specific trusted anchor that lexically contains it, +resolving any symbolic links or junctions encountered along the way. +.PARAMETER Path +The literal path to resolve. +.PARAMETER AnchorPaths +An array of trusted anchor paths (folders, or specific files/reparse points) to start resolution from; +reparse points within these paths are ignored (or they are otherwise known to be free of reparse points). +The function will start resolution from the most specific entry in this list that lexically contains $Path +(or that equals $Path exactly). +.OUTPUTS +The resolved path (including any nonexistent trailing segments), or $null if a path segment could not be +inspected or the reparse point chain exceeded the hop limit. The filesystem root is a fallback anchor. +#> +function Resolve-PathPhysically { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [string[]] $AnchorPaths = @() + ) + + $realPath = Resolve-PathLexically -Path $Path # canonicalize the path to an absolute path + + $segments = [System.Collections.Generic.List[string]]::new() + $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 + + # Start from the deepest trusted anchor that contains the target path. + $anchorPath = @($AnchorPaths) + @([System.IO.Path]::GetPathRoot($realPath)) | # combine user-provided anchor folders with the root of the target path + ForEach-Object { Resolve-PathLexically -Path $_ -AsDirectory } | # canonicalize each anchor folder to an absolute path as a directory + Sort-Object -Descending | # nested paths sort before their containing prefixes + Where-Object { Test-PathLexicallyContained -Path $realPath -RootFolder $_ } | # filter only those anchor folders that lexically contain the target path + Select-Object -First 1 + + if ($realPath.Length -gt $anchorPath.Length) { + $segments.AddRange([string[]] $realPath.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $anchorPath + } + + while ($segments.Count -gt 0) { + $realPath = Join-Path $realPath $segments[0] + $segments.RemoveAt(0) + + if (-not $resolveReparsePoints) { + continue + } + + try { + $item = Get-Item -LiteralPath $realPath -Force -ErrorAction Stop + } + catch [System.Management.Automation.ItemNotFoundException] { + $resolveReparsePoints = $false + continue + } + catch { + OutputWarning "Path '$Path' could not be resolved: unable to inspect '$realPath'." + return $null + } + + if ($null -eq $item) { + # Required for PS5.1 as Get-Item may return $null instead of throwing when the item does not exist + $resolveReparsePoints = $false + continue + } + if ($item.LinkType -notin @('SymbolicLink', 'Junction') -or -not $item.Target) { + if ($item -is [System.IO.DirectoryInfo]) { + $AnchorPaths += $item.FullName # this segment itself is confirmed not a reparse point + } + continue + } + + if ($hopCount++ -ge $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'." + return $null + } + + $realPath = @($item.Target)[0] + if (-not [System.IO.Path]::IsPathRooted($realPath)) { + $realPath = Join-Path $item.Parent.FullName $realPath + } + $realPath = Resolve-PathLexically -Path $realPath + + # Recheck from the deepest trusted anchor after following the link target. + $anchorPath = @($AnchorPaths) + @([System.IO.Path]::GetPathRoot($realPath)) | # combine user-provided anchor folders with the root of the target path + ForEach-Object { Resolve-PathLexically -Path $_ -AsDirectory } | # canonicalize each anchor folder to an absolute path as a directory + Sort-Object -Descending | # nested paths sort before their containing prefixes + Where-Object { Test-PathLexicallyContained -Path $realPath -RootFolder $_ } | # filter only those anchor folders that lexically contain the target path + Select-Object -First 1 + + if ($realPath.Length -gt $anchorPath.Length) { + # 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 resolve on a later iteration. + $segments.InsertRange(0, [string[]] $realPath.Substring($anchorPath.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)) + $realPath = $anchorPath + } + } + + return $realPath +} + +<# +.SYNOPSIS +Checks whether a path physically resolves to itself, i.e. no reparse point along the way redirects it. +.DESCRIPTION +Resolves $Path (see Resolve-PathPhysically) and compares the result to $Path itself. +.PARAMETER Path +The literal path expected to be its own final physical location. +.PARAMETER AnchorPaths +An array of trusted anchor paths (folders, or specific files/reparse points) to start resolution from; +reparse points within these paths are ignored (or they are otherwise known to be free of reparse points). +.OUTPUTS +$true if $Path resolves to itself (including a nonexistent trailing path), otherwise $false. +$false also means physical resolution failed. +#> +function Test-PathPhysicallyEqual { + Param( + [Parameter(Mandatory=$true)] + [string] $Path, + [string[]] $AnchorPaths = @() + ) + + $pathComparer = GetPathStringComparer + + $Path = Resolve-PathLexically -Path $Path + + $realPath = Resolve-PathPhysically -Path $Path -AnchorPaths $AnchorPaths + if (-not $realPath) { + return $false + } + + return $pathComparer.Equals($realPath, $Path) +} + <# .SYNOPSIS Resolves file paths based on the provided source folder, destination folder, and file specifications. @@ -809,6 +1039,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. +Both source and destination boundary checks are lexical-only (see Test-PathLexicallyContained, resolving ".."/"." segments) - neither does a filesystem-aware symlink/junction walk here. Physical resolution (see Test-PathPhysicallyEqual/Resolve-PathPhysically) is instead re-checked immediately before each source file is actually read and each destination file is actually written/removed, in CheckForUpdates.ps1: for the destination this is required, since the destination folder is later relocated into a new clone (see CloneIntoNewFolder) before files are written, so a filesystem-based check here would validate the wrong location anyway; for the source there is no such relocation, but checking at the point of use keeps both sides symmetric and covers the file exactly as it will be read. 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. @@ -856,6 +1087,9 @@ function ResolveFilePaths { return @() } + $sourceFolder = Resolve-PathLexically -Path $sourceFolder -AsDirectory + $destinationFolder = Resolve-PathLexically -Path $destinationFolder -AsDirectory + $fullFilePaths = @() foreach($file in $files) { if($file.Keys -notcontains 'sourceFolder') { @@ -891,7 +1125,12 @@ function ResolveFilePaths { # All files are relative to the template folder OutputDebug "Resolving files for source folder '$($file.sourceFolder)' and filter '$($file.filter)'" - $sourceFiles = @(Get-ChildItem -Path (Join-Path $sourceFolder $file.sourceFolder) -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + $fileSourceFolder = Join-Path $sourceFolder $file.sourceFolder + if (-not (Test-PathLexicallyContained -Path $fileSourceFolder -RootFolder $sourceFolder)) { + OutputDebug "Skipping source folder '$fileSourceFolder' as it is not under the source folder '$sourceFolder'." + continue + } + $sourceFiles = @(Get-ChildItem -LiteralPath $fileSourceFolder -Filter $file.filter -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) OutputDebug "Found $($sourceFiles.Count) files for filter '$($file.filter)' in folder '$($file.sourceFolder)' (relative to folder '$sourceFolder', origin '$($file.origin)')" @@ -908,8 +1147,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 (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $srcFile -RootFolder $sourceFolder)) { OutputDebug "Skipping source file '$($srcFile)' as it is not under the source folder '$($sourceFolder)'." continue } @@ -918,12 +1157,18 @@ 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) { - # If the file exists in the original template folder, use that file instead - $fullFilePath.originalSourceFullPath = Join-Path $originalSourceFolder $relativePath -Resolve + $relativeSourceFile = $srcFile.Substring($sourceFolder.Length) + $originalSourceFile = Join-Path $originalSourceFolder $relativeSourceFile + $originalSourceFile = Resolve-PathLexically -Path $originalSourceFile + if (Test-Path -LiteralPath $originalSourceFile -PathType Leaf) { + if (Test-PathLexicallyContained -Path $originalSourceFile -RootFolder $originalSourceFolder) { + # If the file exists in the original template folder, use that file instead + $fullFilePath.originalSourceFullPath = $originalSourceFile + } + else { + OutputWarning "Skipping source file '$srcFile' as the original source file '$originalSourceFile' is not under the original source folder '$originalSourceFolder'." + continue + } } } @@ -941,16 +1186,41 @@ function ResolveFilePaths { $project = '' # If project is '.', it means the root folder, so we use an empty string } - $fullProjectFilePath = $fullFilePath.Clone() + $unresolvedProjectDestinationFolder = Join-Path $destinationFolder $project + $unresolvedProjectDestinationFolder = Join-Path $unresolvedProjectDestinationFolder '' # Ensure unresolved project destination folder has a trailing slash for correct path resolution + $projectDestinationFolder = Resolve-PathLexically -Path $unresolvedProjectDestinationFolder -AsDirectory - $fullProjectFilePath.destinationFullPath = Join-Path $destinationFolder $project - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $file.destinationFolder - $fullProjectFilePath.destinationFullPath = Join-Path $fullProjectFilePath.destinationFullPath $destinationName + # 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 (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -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 = Resolve-PathLexically -Path $fileDestinationFolder -AsDirectory + + # Check if the destination folder is under the project destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -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 = Resolve-PathLexically -Path $fullProjectFilePath.destinationFullPath - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullProjectFilePath.destinationFullPath) { - OutputDebug "Skipping duplicate per-project file for project '$project': destinationFullPath '$($fullProjectFilePath.destinationFullPath)' already exists" + # Check if the destination file is under the file destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $fullProjectFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + OutputWarning "Skipping file '$srcFile' for project '$project': destination file '$($fullProjectFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." continue } + OutputDebug "Adding per-project file for project '$project': sourceFullPath '$($fullProjectFilePath.sourceFullPath)', originalSourceFullPath '$($fullProjectFilePath.originalSourceFullPath)', destinationFullPath '$($fullProjectFilePath.destinationFullPath)'" $fullFilePaths += $fullProjectFilePath } @@ -959,13 +1229,24 @@ 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 = Resolve-PathLexically -Path $fileDestinationFolder -AsDirectory + + # Check if the destination folder is under the base destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -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 = Resolve-PathLexically -Path $fullFilePath.destinationFullPath - if($fullFilePaths -and $fullFilePaths.destinationFullPath -contains $fullFilePath.destinationFullPath) { - OutputDebug "Skipping duplicate file: destinationFullPath '$($fullFilePath.destinationFullPath)' already exists" + # Check if the destination file is under the file destination folder (lexical, resolves ".."/".") + if (-not (Test-PathLexicallyContained -Path $fullFilePath.destinationFullPath -RootFolder $fileDestinationFolder)) { + OutputWarning "Skipping file '$srcFile': destination file '$($fullFilePath.destinationFullPath)' is outside the file destination folder '$fileDestinationFolder'." continue } + OutputDebug "Adding file: sourceFullPath '$($fullFilePath.sourceFullPath)', originalSourceFullPath '$($fullFilePath.originalSourceFullPath)', destinationFullPath '$($fullFilePath.destinationFullPath)'" $fullFilePaths += $fullFilePath } @@ -1023,12 +1304,85 @@ 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. + Both copy endpoints (the snapshot file under baseFolder and the settings file under customTemplateFolder) are + validated to physically resolve to themselves before either is read or written; the function throws if a + symlink/junction anywhere along either path would redirect the backup, copy or restore to a different physical location +.PARAMETER baseFolder + The base folder of the repository whose settings are read. Defaults to GITHUB_WORKSPACE. +.PARAMETER customTemplateFolder + The folder where the custom template files are located. +.PARAMETER workflowName + The workflow whose settings should be included, if specified. +#> +function ReadSettingsWithCurrentCustomTemplateRepoSettings { + Param( + [string] $baseFolder = "$ENV:GITHUB_WORKSPACE", + [Parameter(Mandatory=$true)] + [string] $customTemplateFolder, + [string] $workflowName = '' + ) + + $customTemplateRepoSettingsPath = Join-Path $customTemplateFolder $RepoSettingsFile + $baseFolderCustomTemplateSettingsPath = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + + # Validate both copy endpoints before touching them (even if the leaf file doesn't exist yet): a symlink/junction + # anywhere along either path can make it resolve to a different physical location than its literal path + if (-not (Test-PathPhysicallyEqual -Path $baseFolderCustomTemplateSettingsPath -AnchorPaths @($baseFolder))) { + throw "Cannot read settings: '$baseFolderCustomTemplateSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + } + if (-not (Test-PathPhysicallyEqual -Path $customTemplateRepoSettingsPath -AnchorPaths @($customTemplateFolder))) { + throw "Cannot read settings: '$customTemplateRepoSettingsPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + } + + $baseFolderCustomTemplateSettingsBackupPath = $null + + if (Test-Path -LiteralPath $baseFolderCustomTemplateSettingsPath -PathType Leaf) { + $baseFolderCustomTemplateSettingsBackupPath = Join-Path (GetTemporaryPath) ([Guid]::NewGuid().ToString()) + Copy-Item -LiteralPath $baseFolderCustomTemplateSettingsPath -Destination $baseFolderCustomTemplateSettingsBackupPath -Force + } + + try { + if (Test-Path -LiteralPath $customTemplateRepoSettingsPath -PathType Leaf) { + Copy-Item -LiteralPath $customTemplateRepoSettingsPath -Destination $baseFolderCustomTemplateSettingsPath -Force + } + # Keep execution-specific contexts empty for both file selection and workflow-specific reads. + return ReadSettings -baseFolder $baseFolder -buildMode '' -project '' -workflowName $workflowName -userName '' -branchName '' -trigger '' | ConvertTo-HashTable -recurse + } + finally { + if ($baseFolderCustomTemplateSettingsBackupPath) { + Copy-Item -LiteralPath $baseFolderCustomTemplateSettingsBackupPath -Destination $baseFolderCustomTemplateSettingsPath -Force + Remove-Item -LiteralPath $baseFolderCustomTemplateSettingsBackupPath -Force + } + elseif (Test-Path -LiteralPath $baseFolderCustomTemplateSettingsPath -PathType Leaf) { + Remove-Item -LiteralPath $baseFolderCustomTemplateSettingsPath -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 @@ -1036,15 +1390,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. @@ -1061,6 +1417,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 @@ -1071,49 +1428,78 @@ 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) + } - # 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) + } + + # 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) } + # 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 { + $include = $filesToIncludeDestinationFullPaths.Add($_.destinationFullPath) + if (-not $include) { OutputDebug "Skipping duplicate file to include '$($_.sourceFullPath)': destinationFullPath '$($_.destinationFullPath)' already included" } + return $include + }) + + # Exclude files from filesToExclude that are still included in filesToInclude based on destinationFullPath + $filesToExclude = @($filesToExclude | Where-Object { + $exclude = -not $filesToIncludeDestinationFullPaths.Contains($_.destinationFullPath) + if (-not $exclude) { OutputDebug "Skipping file to exclude '$($_.sourceFullPath)': destinationFullPath '$($_.destinationFullPath)' included with different source" } + return $exclude + }) + # Deduplicate files to exclude based on destinationFullPath, keeping the first one (default > settings; template folder > original template folder) + $filesToExcludeDestinationFullPaths = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + $filesToExclude = @($filesToExclude | Where-Object { + $exclude = $filesToExcludeDestinationFullPaths.Add($_.destinationFullPath) + if (-not $exclude) { OutputDebug "Skipping duplicate file to exclude '$($_.sourceFullPath)': destinationFullPath '$($_.destinationFullPath)' already excluded" } + return $exclude + }) + # List all files to be included and excluded with their source and destination paths, type and original source path (if any) $fileFormatter = { param($file) " -Source: $($file.sourceFullPath), Destination: $($file.destinationFullPath), Type: $($file.type), Original Source: $($file.originalSourceFullPath)"} OutputArray -Message "Files to include: $($filesToInclude.Count)" -Array $filesToInclude -Formatter $fileFormatter diff --git a/Actions/CheckForUpdates/CheckForUpdates.ps1 b/Actions/CheckForUpdates/CheckForUpdates.ps1 index 6d4d4d82e7..27a9e6da3c 100644 --- a/Actions/CheckForUpdates/CheckForUpdates.ps1 +++ b/Actions/CheckForUpdates/CheckForUpdates.ps1 @@ -1,4 +1,4 @@ -Param( +Param( [Parameter(HelpMessage = "The GitHub actor running the action", Mandatory = $false)] [string] $actor, [Parameter(HelpMessage = "Base64 encoded GhTokenWorkflow secret", Mandatory = $false)] @@ -51,8 +51,9 @@ if ($token) { # if $update is set to N, CheckForUpdates will only check for updates and output a warning if there are updates available # 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 +# Get repo settings independent of the build, project, workflow, user, branch, and trigger running this update. +# Repository-scoped and unconditional settings still apply. +$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 +114,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 -customTemplateFolder $templateFolder +} + $projects = @(GetProjectsFromRepository -baseFolder $baseFolder -projectsFromSettings $repoSettings.projects) $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $repoSettings -projects $projects -baseFolder $baseFolder -templateFolder $templateFolder -originalTemplateFolder $originalTemplateFolder @@ -133,6 +140,14 @@ if ($projects.Count -gt 1) { Write-Host "Calculated dependency depth to be $depth" } +# Prepare the list of template folders to be used for verification +$templateFolders = @($templateFolder) +$customTemplateFolder = '' +if ($originalTemplateFolder) { + $templateFolders += $originalTemplateFolder + $customTemplateFolder = $templateFolder +} + # Loop through all folders in CheckFiles and check if there are any files that needs to be updated foreach($fileToInclude in $filesToInclude) { $type = $fileToInclude.type @@ -141,17 +156,37 @@ foreach($fileToInclude in $filesToInclude) { if(-not $originalSrcPath) { $originalSrcPath = $srcPath } + $hasDistinctOriginalSource = -not (GetPathStringComparer).Equals($originalSrcPath, $srcPath) + + # Skip files that do not physically resolve to themselves within the template folders + if (-not (Test-PathPhysicallyEqual -Path $srcPath -AnchorPaths $templateFolders)) { + OutputWarning "Skipping update for source file '$srcPath': source does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." + continue + } + if ($hasDistinctOriginalSource) { + # Skip files with original files that do not physically resolve to themselves within the template folders + if (-not (Test-PathPhysicallyEqual -Path $originalSrcPath -AnchorPaths $templateFolders)) { + OutputWarning "Skipping update for source file '$srcPath': original source '$originalSrcPath' does not physically resolve to itself within the template folder(s). This may indicate a symlink/junction redirect." + continue + } + } $dstPath = $fileToInclude.destinationFullPath - $dstFileExists = Test-Path -Path $dstPath -PathType Leaf + # Skip files with destinations that do not physically resolve to themselves within the base folder + if (-not (Test-PathPhysicallyEqual -Path $dstPath -AnchorPaths @($baseFolder))) { + OutputWarning "Skipping update for source file '$srcPath': destination '$dstPath' does not physically resolve to itself. This may indicate a symlink/junction redirect." + continue + } + + $dstFileExists = Test-Path -LiteralPath $dstPath -PathType Leaf Write-Host "Processing file: $srcPath -> $dstPath (type: $type)" switch ($type) { "workflow" { # For workflow files, we might need to modify the file based on the settings - $srcContent = GetWorkflowContentWithChangesFromSettings -srcFile $originalSrcPath -repoSettings $repoSettings -depth $depth + $srcContent = GetWorkflowContentWithChangesFromSettings -srcFile $originalSrcPath -repoSettings $repoSettings -depth $depth -customTemplateFolder $customTemplateFolder # Replace static placeholders $srcContent = $srcContent.Replace('{TEMPLATEURL}', $templateUrl) } @@ -170,7 +205,7 @@ foreach($fileToInclude in $filesToInclude) { ReplaceOwnerRepoAndBranch -srcContent ([ref]$srcContent) -templateOwner $templateOwner -templateBranch $templateBranch } - if ($type -eq 'workflow' -and $originalSrcPath -ne $srcPath) { + if ($type -eq 'workflow' -and $hasDistinctOriginalSource) { # Apply customizations from custom template repository Write-Host "Apply customizations from custom template repository, file: $srcPath" [Yaml]::ApplyTemplateCustomizations([ref] $srcContent, $srcPath) @@ -204,8 +239,8 @@ foreach($fileToInclude in $filesToInclude) { Push-Location -Path $baseFolder # Remove files that are in $filesToExclude and exist in the repository -$removeFiles = $filesToExclude | Where-Object { $_ -and (Test-Path -Path $_.destinationFullPath -PathType Leaf) } | ForEach-Object { - $relativePath = Resolve-Path -Path $_.destinationFullPath -Relative +$removeFiles = $filesToExclude | Where-Object { $_ -and (Test-Path -LiteralPath $_.destinationFullPath -PathType Leaf) } | ForEach-Object { + $relativePath = Resolve-Path -LiteralPath $_.destinationFullPath -Relative Write-Host "File marked for removal: $relativePath" $relativePath } @@ -256,16 +291,24 @@ else { invoke-git status + $dstRoot = (Get-Location).Path + # Update the files # Calculate the release notes, while updating $releaseNotes = "" $updateFiles | ForEach-Object { + # Skip files that do not physically resolve to themselves within the destination root folder + if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_.DstFile) -AnchorPaths @($dstRoot))) { + OutputWarning "Skipping update of '$($_.DstFile)': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." + return + } + # Create the destination folder if it doesn't exist $path = [System.IO.Path]::GetDirectoryName($_.DstFile) - if ($path -and -not (Test-Path -path $path -PathType Container)) { + if ($path -and -not (Test-Path -LiteralPath $path -PathType Container)) { New-Item -Path $path -ItemType Directory | Out-Null } - if (([System.IO.Path]::GetFileName($_.DstFile) -eq "RELEASENOTES.copy.md") -and (Test-Path $_.DstFile)) { + if (([System.IO.Path]::GetFileName($_.DstFile) -eq "RELEASENOTES.copy.md") -and (Test-Path -LiteralPath $_.DstFile)) { # Read the release notes of the version currently installed $oldReleaseNotes = Get-ContentLF -Path $_.DstFile # Get the release notes of the new version (for the PR body) @@ -287,8 +330,14 @@ else { $releaseNotes = "No release notes available!" } $removeFiles | ForEach-Object { + # Skip files that do not physically resolve to themselves within the destination root folder + if (-not (Test-PathPhysicallyEqual -Path (Join-Path $dstRoot $_) -AnchorPaths @($dstRoot))) { + OutputWarning "Skipping removal of '$_': destination does not physically resolve to itself. This may indicate a symlink/junction redirect." + return + } + Write-Host "Remove $_" - Remove-Item (Join-Path (Get-Location).Path $_) -Force + Remove-Item -LiteralPath $_ -Force } # Update the templateUrl and templateSha in the repo settings file diff --git a/Actions/CheckForUpdates/yamlclass.ps1 b/Actions/CheckForUpdates/yamlclass.ps1 index 68ee7683f3..1a03a3464a 100644 --- a/Actions/CheckForUpdates/yamlclass.ps1 +++ b/Actions/CheckForUpdates/yamlclass.ps1 @@ -19,7 +19,7 @@ class Yaml { # Static load function to load a Yaml file into a Yaml class static [Yaml] Load([string] $filename) { - $fileContent = Get-Content -Path $filename -Encoding UTF8 + $fileContent = Get-Content -LiteralPath $filename -Encoding UTF8 return [Yaml]::new($fileContent) } diff --git a/Actions/Github-Helper.psm1 b/Actions/Github-Helper.psm1 index 4c3f185549..c7fcc11620 100644 --- a/Actions/Github-Helper.psm1 +++ b/Actions/Github-Helper.psm1 @@ -859,7 +859,7 @@ function Get-ContentLF { ) Process { - (Get-Content -Path $path -Encoding UTF8 -Raw).Replace("`r", "").TrimEnd("`n") + (Get-Content -LiteralPath $path -Encoding UTF8 -Raw).Replace("`r", "").TrimEnd("`n") } } diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b6e473b2bd..d1eed20e38 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). + ### Allow pre-release packages as NuGet dependencies AL-Go now supports including pre-release versions of Business Central app packages from NuGet feeds. To enable this, append `-allowPrerelease` to the `nuGetFeedSelectMode` setting in your project configuration. For example, `LatestMatching-allowPrerelease` will select the latest matching version of the package, including pre-release versions. diff --git a/Scenarios/CustomizingALGoForGitHub.md b/Scenarios/CustomizingALGoForGitHub.md index f5d80b24ad..5e8a796491 100644 --- a/Scenarios/CustomizingALGoForGitHub.md +++ b/Scenarios/CustomizingALGoForGitHub.md @@ -235,14 +235,30 @@ In order to instruct AL-Go which files to look for at the template repository, y `filesToInclude`, as the name suggests, is an array of file configurations that will instruct AL-Go which files to include (create/update). 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. `*` characters are not supported. _Example_: `src/scripts`. +- `sourceFolder`: A literal path to a folder, relative to the template, where to look for files. If not specified the root folder is implied. Wildcards are not supported; characters such as `[` and `]` are treated literally. _Example_: `src/scripts`. - `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**: if the destination file already exists, its settings are preserved and only its `$schema` is updated from the original template. If it does not exist, the original template's settings are used. Current custom template repository settings are also read when updating workflows. +- **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 +267,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 eaeeb42ea9..709e3690a1 100644 --- a/Scenarios/settings.md +++ b/Scenarios/settings.md @@ -189,6 +189,8 @@ to your [project settings file](#where-are-the-settings-located) will ensure tha - **users** settings will be applied for users matching the patterns - **triggers** settings will be applied when `GITHUB_EVENT_NAME` matches values (for example `push`, `pull_request`, `schedule`, `workflow_dispatch`) +When updating AL-Go System Files, settings for selecting files are read without a build mode, project, workflow, user, branch, or trigger context. When generating each workflow, settings are read again with that workflow's name, so workflow-specific settings and conditions on that workflow apply. Neither read uses the event that triggered the update. Unconditional settings and conditions matching the repository still apply; conditions on other execution contexts do not apply. + **Note:** You can use `workflowDefaultInputs` within conditional settings to apply workflow input defaults only when certain conditions are met. For example, you could set different default values for specific workflows or branches. You could imagine that you could have an organizational settings variable containing: @@ -248,7 +250,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 fb17486894..2fac887fac 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" @@ -49,6 +71,471 @@ Describe "CheckForUpdates Action Tests" { } } +Describe "CheckForUpdates Action: runtime behavior" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'scriptPath', Justification = 'False positive.')] + $scriptPath = Join-Path $scriptRoot "$actionName.ps1" + . (Join-Path -Path $scriptRoot -ChildPath "..\AL-Go-Helper.ps1" -Resolve) + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "checkForUpdatesRuntimeTests" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue + } + + BeforeEach { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalGitHubWorkspace', Justification = 'False positive.')] + $originalGitHubWorkspace = $env:GITHUB_WORKSPACE + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalGitHubRepository', Justification = 'False positive.')] + $originalGitHubRepository = $env:GITHUB_REPOSITORY + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'originalLocation', Justification = 'False positive.')] + $originalLocation = Get-Location + + $testRepoName = 'contoso/check-for-updates-runtime-tests' + + $testTemplateFolder = Join-Path $rootFolder 'template' + $testWorkspaceFolder = Join-Path $rootFolder 'workspace' + $testCloneRoot = Join-Path $rootFolder 'clone' + + $testSettings = @{ + templateSha = 'sha123456789' + templateUrl = 'https://github.com/microsoft/AL-Go-PTE@main' + type = 'PTE' + projects = @() + 'runs-on' = 'windows-latest' + shell = 'powershell' + } + + $testFilesToInclude = @() + $testFilesToExclude = @() + + $fakeToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('fake-pat')) + + Mock DownloadAndImportBcContainerHelper {} + Mock ReadSettings { [PSCustomObject]$testSettings } + Mock DownloadTemplateRepository { return $testTemplateFolder } + Mock GetSrcFolder { return $testTemplateFolder } + Mock IsDirectALGo { return $true } + Mock GetProjectsFromRepository { return @('.') } + Mock GetFilesToUpdate { Write-Output $testFilesToInclude -NoEnumerate; Write-Output $testFilesToExclude -NoEnumerate } + Mock OutputWarning {} + Mock OutputNotice {} + Mock RunAndCheck { return 'sha123456789' } + Mock invoke-git {} + Mock GetAccessToken { return $fakeToken } + Mock gh { '[]' } + Mock CloneIntoNewFolder { Set-Location -Path $testCloneRoot; "https://fake.example.com/$testRepoName.git"; 'update-al-go-system-files/branch' } + Mock CommitFromNewFolder { return $true } + Mock UpdateSettingsFile {} + + New-Item -Path $testTemplateFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testWorkspaceFolder -ItemType Directory -Force | Out-Null + New-Item -Path $testCloneRoot -ItemType Directory -Force | Out-Null + + Set-Location $testWorkspaceFolder + $env:GITHUB_WORKSPACE = $testWorkspaceFolder + $env:GITHUB_REPOSITORY = $testRepoName + } + + AfterEach { + Set-Location $originalLocation + $env:GITHUB_WORKSPACE = $originalGitHubWorkspace + $env:GITHUB_REPOSITORY = $originalGitHubRepository + + if (Test-Path -LiteralPath $testTemplateFolder) { + Remove-Item -Path $testTemplateFolder -Recurse -Force + } + if (Test-Path -LiteralPath $testWorkspaceFolder) { + Remove-Item -Path $testWorkspaceFolder -Recurse -Force + } + if (Test-Path -LiteralPath $testCloneRoot) { + Remove-Item -Path $testCloneRoot -Recurse -Force + } + } + + It 'Reads initial settings without execution-specific contexts' { + . $scriptPath -templateUrl $testSettings.templateUrl -downloadLatest $true -update N + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } + + It 'Reads, updates and removes with literal bracketed paths' { + $newSource = Join-Path $testTemplateFolder 'New[1].txt' + $newDestination = Join-Path $testWorkspaceFolder 'New[1].txt' + Set-Content -LiteralPath $newSource -Value 'new content' + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'New1.txt') -Value 'wrong source' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'New1.txt') -Value 'wrong destination' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'New1.txt') -Value 'keep me' + + $changedSource = Join-Path $testTemplateFolder 'Changed[1].txt' + $changedDestination = Join-Path $testWorkspaceFolder 'Changed[1].txt' + Set-Content -LiteralPath $changedSource -Value 'new content' + Set-Content -LiteralPath $changedDestination -Value 'old content' + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'Changed1.txt') -Value 'wrong source' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Changed1.txt') -Value 'wrong destination' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Changed[1].txt') -Value 'old content' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Changed1.txt') -Value 'keep me' + + $unchangedSource = Join-Path $testTemplateFolder 'Same[1].txt' + $unchangedDestination = Join-Path $testWorkspaceFolder 'Same[1].txt' + Set-Content -LiteralPath $unchangedSource -Value 'same content' + Set-Content -LiteralPath $unchangedDestination -Value 'same content' + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'Same1.txt') -Value 'wrong source' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Same1.txt') -Value 'wrong destination' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Same[1].txt') -Value 'same content' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Same1.txt') -Value 'keep me' + + $workflowSource = Join-Path $testTemplateFolder 'Workflow[1].yaml' + $workflowOriginalSource = Join-Path $testTemplateFolder 'WorkflowOriginal[1].yaml' + $workflowDestination = Join-Path $testWorkspaceFolder 'Workflow[1].yaml' + $workflowLines = @('jobs:', ' Build:', ' runs-on: [ windows-latest ]') + $workflowTemplateCustomJobLines = @(' CustomJob-Template:', ' runs-on: [ windows-latest ]') + $workflowDestinationCustomJobLines = @(' CustomJob-Destination:', ' runs-on: [ windows-latest ]') + Set-Content -LiteralPath $workflowSource -Value (@('name: Custom') + $workflowLines + $workflowTemplateCustomJobLines) + Set-Content -LiteralPath $workflowOriginalSource -Value (@('name: Original') + $workflowLines) + Set-Content -LiteralPath $workflowDestination -Value (@('name: Destination') + $workflowLines + $workflowDestinationCustomJobLines) + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'Workflow1.yaml') -Value (@('name: Wrong custom') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testTemplateFolder 'WorkflowOriginal1.yaml') -Value (@('name: Wrong original') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Workflow1.yaml') -Value (@('name: Wrong destination') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow[1].yaml') -Value (@('name: Old content') + $workflowLines) + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow1.yaml') -Value (@('name: Keep me') + $workflowLines) + + $excludedDestination = Join-Path $testWorkspaceFolder 'Old[1].txt' + Set-Content -LiteralPath $excludedDestination -Value 'remove me' + Set-Content -LiteralPath (Join-Path $testWorkspaceFolder 'Old1.txt') -Value 'keep me' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Old[1].txt') -Value 'remove me' + Set-Content -LiteralPath (Join-Path $testCloneRoot 'Old1.txt') -Value 'keep me' + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $newSource; originalSourceFullPath = $null; type = ''; destinationFullPath = $newDestination } + @{ sourceFullPath = $changedSource; originalSourceFullPath = $null; type = ''; destinationFullPath = $changedDestination } + @{ sourceFullPath = $unchangedSource; originalSourceFullPath = $null; type = ''; destinationFullPath = $unchangedDestination } + @{ sourceFullPath = $workflowSource; originalSourceFullPath = $workflowOriginalSource; type = 'workflow'; destinationFullPath = (Join-Path $testWorkspaceFolder 'Workflow[1].yaml') } + ) + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToExclude', Justification = 'False positive.')] + $testFilesToExclude = @( + @{ destinationFullPath = $excludedDestination } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update Y -updateBranch 'update-al-go-system-files' -token $fakeToken -directCommit $true -actor 'test-actor' + + $updateFiles.Count | Should -Be 3 + $updateFiles[0].DstFile | Should -Be 'New[1].txt' + $updateFiles[0].content | Should -Be 'new content' + $updateFiles[1].DstFile | Should -Be 'Changed[1].txt' + $updateFiles[1].content | Should -Be 'new content' + $updateFiles[2].DstFile | Should -Be 'Workflow[1].yaml' + $updateFiles[2].content | Should -Match '(?s)^name: Original.*CustomJob-Template:.*CustomJob-Destination:' + @($removeFiles).Count | Should -Be 1 + @($removeFiles)[0] | Should -Be (Join-Path '.' 'Old[1].txt') + + Get-Content -LiteralPath (Join-Path $testCloneRoot 'New[1].txt') | Should -Be 'new content' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'New1.txt') | Should -Be 'keep me' + + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Changed[1].txt') | Should -Be 'new content' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Changed1.txt') | Should -Be 'keep me' + + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Same[1].txt') | Should -Be 'same content' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Same1.txt') | Should -Be 'keep me' + + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow[1].yaml') -Raw | Should -Match '(?s)^name: Original.*CustomJob-Template:.*CustomJob-Destination:' + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Workflow1.yaml') -Raw | Should -Match '^name: Keep me' + + Test-Path -LiteralPath (Join-Path $testCloneRoot 'Old[1].txt') | Should -BeFalse + Get-Content -LiteralPath (Join-Path $testCloneRoot 'Old1.txt') | Should -Be 'keep me' + } + + It 'Rejects an external original source on Linux' -Skip:(-not $script:isLinuxPlatform) { + $invalidSource = Join-Path $testTemplateFolder 'File.txt' + $invalidOriginalSource = Join-Path $testTemplateFolder 'file.txt' + Set-Content -LiteralPath $invalidSource -Value 'safe content' + $external = Join-Path $TestDrive 'file.txt' + Set-Content -LiteralPath $external -Value 'external content' + New-Item -ItemType SymbolicLink -Path $invalidOriginalSource -Target $external | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $invalidSource; originalSourceFullPath = $invalidOriginalSource; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder 'File.txt') } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like '*original source*does not physically resolve*' } + $updateFiles.Count | Should -Be 0 + } + + It 'Applies workflow customizations from case-distinct original sources on Linux' -Skip:(-not $script:isLinuxPlatform) { + $workflowSource = Join-Path $testTemplateFolder 'Workflow.yaml' + $workflowOriginalSource = Join-Path $testTemplateFolder 'WORKFLOW.yaml' + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt') -Destination $workflowSource + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'YamlSnippet.txt') -Destination $workflowOriginalSource + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $workflowSource; originalSourceFullPath = $workflowOriginalSource; type = 'workflow'; destinationFullPath = (Join-Path $testWorkspaceFolder 'Workflow.yaml') } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like '*There are updates for your AL-Go system*' } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be 'Workflow.yaml' + $updateFiles[0].content | Should -Match 'CustomJob-MyCustomTemplateJob:' + } + + It 'Applies no workflow customizations from case-distinct original sources on Windows' -Skip:(-not $script:isWindowsPlatform) { + $workflowSource = Join-Path $testTemplateFolder 'Workflow.yaml' + $workflowOriginalSource = Join-Path $testTemplateFolder 'WORKFLOW.yaml' + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'CustomizedYamlSnippet-TemplateRepository.txt') -Destination $workflowSource + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'YamlSnippet.txt') -Destination $workflowOriginalSource + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $workflowSource; originalSourceFullPath = $workflowOriginalSource; type = 'workflow'; destinationFullPath = (Join-Path $testWorkspaceFolder 'Workflow.yaml') } + ) + + . $scriptPath -templateUrl 'https://github.com/contoso/template@main' -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like '*There are updates for your AL-Go system*' } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be 'Workflow.yaml' + $updateFiles[0].content | Should -Not -Match 'CustomJob-MyCustomTemplateJob:' + } + + It 'CheckForUpdates skips source files that do not physically resolve to themselves using junctions' -Skip:(-not $script:isWindowsPlatform) { + # A legitimate file directly in the template folder + $legitTemplateFile = Join-Path $testTemplateFolder "legit.txt" + Set-Content -LiteralPath $legitTemplateFile -Value "legit content" + + # Create a redirection folder and a junction pointing back to the template folder + $redirectedTemplateFolder = Join-Path $testTemplateFolder "redirected" + $redirectedTemplateFile = Join-Path $redirectedTemplateFolder "redirected.txt" + New-Item -ItemType Junction -Path $redirectedTemplateFolder -Target $testTemplateFolder -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $redirectedTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $redirectedTemplateFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*redirected.txt': source does not physically resolve*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*': original source '*redirected.txt' does not physically resolve*" } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be "legit.txt" + } + + It 'CheckForUpdates skips source files that do not physically resolve to themselves using symlinks' -Skip:(-not $script:hasSymlinkCapability) { + # A legitimate file directly in the template folder + $legitTemplateFile = Join-Path $testTemplateFolder "legit.txt" + Set-Content -LiteralPath $legitTemplateFile -Value "legit content" + + # Create a redirection folder and a symbolic link pointing back to the template folder + $redirectedTemplateFolder = Join-Path $testTemplateFolder "redirected" + $redirectedTemplateFile = Join-Path $redirectedTemplateFolder "redirected.txt" + New-Item -ItemType SymbolicLink -Path $redirectedTemplateFolder -Target $testTemplateFolder -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $redirectedTemplateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + @{ sourceFullPath = $legitTemplateFile; originalSourceFullPath = $redirectedTemplateFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*redirected.txt': source does not physically resolve*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "Skipping update for source file '*': original source '*redirected.txt' does not physically resolve*" } + $updateFiles.Count | Should -Be 1 + $updateFiles[0].DstFile | Should -Be "legit.txt" + } + + It 'CheckForUpdates skips source files with case-distinct original sources that do not physically resolve to themselves using symlinks on Linux' -Skip:(-not $script:isLinuxPlatform) { + $directTemplateFolder = Join-Path $testTemplateFolder "folder" + $directTemplateFile = Join-Path $directTemplateFolder "file.txt" + New-Item -ItemType Directory -Path $directTemplateFolder -Force | Out-Null + Set-Content -LiteralPath $directTemplateFile -Value "direct content" + + $redirectedTemplateFolder = Join-Path $testTemplateFolder "FOLDER" + $redirectedTemplateFile = Join-Path $redirectedTemplateFolder "file.txt" + New-Item -ItemType SymbolicLink -Path $redirectedTemplateFolder -Target $testTemplateFolder -Force | Out-Null + Set-Content -LiteralPath $redirectedTemplateFile -Value "redirected content" + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $directTemplateFile; originalSourceFullPath = $redirectedTemplateFile; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "file.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update N + + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -clike "Skipping update for source file '*folder*': original source '*FOLDER*' does not physically resolve*" } + $updateFiles.Count | Should -Be 0 + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when updating files using junctions' -Skip:(-not $script:isWindowsPlatform) { + # A file in the template folder + $templateFile = Join-Path $testTemplateFolder "template.txt" + Set-Content -LiteralPath $templateFile -Value "template content" + + # A legitimate file directly in the clone folder + $legitCloneFile = Join-Path $testCloneRoot "legit.txt" + + # Create a redirected folder and a symbolic link pointing back to the workspace folder + $redirectedWorkspaceFolder = Join-Path $testWorkspaceFolder "redirectedInWorkspace" + New-Item -ItemType Junction -Path $redirectedWorkspaceFolder -Target $testWorkspaceFolder -Force | Out-Null + + # Create a redirected folder and a symbolic link pointing back to the clone folder + $redirectedCloneFolder = Join-Path $testCloneRoot "redirectedInClone" + New-Item -ItemType Junction -Path $redirectedCloneFolder -Target $testCloneRoot -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInWorkspace/redirected.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInClone/redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $updateFiles.Count | Should -Be 2 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update for source file '*template.txt':*'*redirectedInWorkspace*redirected.txt'*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update of '*redirectedInClone*redirected.txt'*" } + + # The legitimate update was actually written to the (mocked) clone root; the redirected ones were not + Test-Path -Path $legitCloneFile -PathType Leaf | Should -Be $true + Test-Path -Path (Join-Path $testCloneRoot "redirectedInWorkspace/redirected.txt") -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot "redirectedInClone/redirected.txt") -PathType Leaf | Should -Be $false + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when updating files using symlinks' -Skip:(-not $script:hasSymlinkCapability) { + # A file in the template folder + $templateFile = Join-Path $testTemplateFolder "template.txt" + Set-Content -LiteralPath $templateFile -Value "template content" + + # A legitimate file directly in the clone folder + $legitCloneFile = Join-Path $testCloneRoot "legit.txt" + + # Create a redirected folder and a symbolic link pointing back to the workspace folder + $redirectedWorkspaceFolder = Join-Path $testWorkspaceFolder "redirectedInWorkspace" + New-Item -ItemType SymbolicLink -Path $redirectedWorkspaceFolder -Target $testWorkspaceFolder -Force | Out-Null + + # Create a redirected folder and a symbolic link pointing back to the clone folder + $redirectedCloneFolder = Join-Path $testCloneRoot "redirectedInClone" + New-Item -ItemType SymbolicLink -Path $redirectedCloneFolder -Target $testCloneRoot -Force | Out-Null + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToInclude', Justification = 'False positive.')] + $testFilesToInclude = @( + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "legit.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInWorkspace/redirected.txt") } + @{ sourceFullPath = $templateFile; originalSourceFullPath = $null; type = ''; destinationFullPath = (Join-Path $testWorkspaceFolder "redirectedInClone/redirected.txt") } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $updateFiles.Count | Should -Be 2 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update for source file '*template.txt':*'*redirectedInWorkspace*redirected.txt'*" } + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping update of '*redirectedInClone*redirected.txt'*" } + + # The legitimate update was actually written to the (mocked) clone root; the redirected ones were not + Test-Path -Path $legitCloneFile -PathType Leaf | Should -Be $true + Test-Path -Path (Join-Path $testCloneRoot "redirectedInWorkspace/redirected.txt") -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot "redirectedInClone/redirected.txt") -PathType Leaf | Should -Be $false + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when removing files using junctions' -Skip:(-not $script:isWindowsPlatform) { + $relativeLegitFile = "legit.txt" + $relativeRedirectedInWorkspaceFolder = "redirectedInWorkspace" + $relativeRedirectedInWorkspaceFile = Join-Path $relativeRedirectedInWorkspaceFolder "redirected.txt" + $relativeRedirectedInCloneFolder = "redirectedInClone" + $relativeRedirectedInCloneFile = Join-Path $relativeRedirectedInCloneFolder "redirected.txt" + + # Create folders, files and junctions in workspace folder + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType Junction -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFolder) -Target $testWorkspaceFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFolder) -Force | Out-Null + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + # Create folders, files and junctions in clone folder + Set-Content -Path (Join-Path $testCloneRoot $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType Directory -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFolder) -Force | Out-Null + New-Item -ItemType Junction -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFolder) -Target $testCloneRoot -Force | Out-Null + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToExclude', Justification = 'False positive.')] + $testFilesToExclude = @( + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeLegitFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $removeFiles.Count | Should -Be 3 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal of '*redirectedInClone*redirected.txt'*" } + + # The legitimate file and the in workspace redirected file were actually removed from the (mocked) clone root; the in clone redirected file was not + Test-Path -Path (Join-Path $testCloneRoot $relativeLegitFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -PathType Leaf | Should -Be $true + } + + It 'CheckForUpdates skips destination files that do not physically resolve to themselves when removing files using symlinks' -Skip:(-not $script:hasSymlinkCapability) { + $relativeLegitFile = "legit.txt" + $relativeRedirectedInWorkspaceFolder = "redirectedInWorkspace" + $relativeRedirectedInWorkspaceFile = Join-Path $relativeRedirectedInWorkspaceFolder "redirected.txt" + $relativeRedirectedInCloneFolder = "redirectedInClone" + $relativeRedirectedInCloneFile = Join-Path $relativeRedirectedInCloneFolder "redirected.txt" + + # Create folders, files and junctions in workspace folder + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType SymbolicLink -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFolder) -Target $testWorkspaceFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFolder) -Force | Out-Null + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + # Create folders, files and junctions in clone folder + Set-Content -Path (Join-Path $testCloneRoot $relativeLegitFile) -Value "legit content" -Force + New-Item -ItemType Directory -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFolder) -Force | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFolder) -Target $testCloneRoot -Force | Out-Null + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -Value "redirected in workspace content" -Force + Set-Content -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -Value "redirected in clone content" -Force + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'testFilesToExclude', Justification = 'False positive.')] + $testFilesToExclude = @( + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeLegitFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInWorkspaceFile) } + @{ destinationFullPath = (Join-Path $testWorkspaceFolder $relativeRedirectedInCloneFile) } + ) + + . $scriptPath -templateUrl "https://github.com/contoso/template@main" -downloadLatest $true -update Y -updateBranch "update-al-go-system-files" -token $fakeToken -directCommit $true -actor "test-actor" + + $removeFiles.Count | Should -Be 3 + Should -Invoke OutputWarning -Exactly 1 -ParameterFilter { $message -like "*Skipping removal of '*redirectedInClone*redirected.txt'*" } + + # The legitimate file and the in workspace redirected file were actually removed from the (mocked) clone root; the in clonde redirected file was not + Test-Path -Path (Join-Path $testCloneRoot $relativeLegitFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInWorkspaceFile) -PathType Leaf | Should -Be $false + Test-Path -Path (Join-Path $testCloneRoot $relativeRedirectedInCloneFile) -PathType Leaf | Should -Be $true + } +} + Describe "Test-HasSystemFileChanges" { BeforeAll { $scriptRoot = Join-Path (Join-Path $PSScriptRoot '..') 'Actions' @@ -144,6 +631,15 @@ Describe "YamlClass Tests" { Mock Trace-Information {} } + It 'Loads only the literal YAML name containing brackets' { + . (Join-Path $scriptRoot 'yamlclass.ps1') + $literalFile = Join-Path $TestDrive 'Workflow[1].yaml' + Set-Content -LiteralPath $literalFile -Value 'name: Correct' + Set-Content -LiteralPath (Join-Path $TestDrive 'Workflow1.yaml') -Value 'name: Wrong' + + [Yaml]::Load($literalFile).content | Should -Be 'name: Correct' + } + It 'Test YamlClass' { . (Join-Path $scriptRoot "yamlclass.ps1") $yaml = [Yaml]::load((Join-Path $PSScriptRoot 'YamlSnippet.txt')) @@ -402,6 +898,18 @@ Describe "CheckForUpdates Action: CheckForUpdates.HelperFunctions.ps1" { $modifiedContent."`$schema" | Should -Be "someSchema" } + It 'GetModifiedSettingsContent reads a literal bracketed destination' { + $source = Join-Path $TestDrive 'SettingsSource.json' + $destination = Join-Path $TestDrive 'Settings[1].json' + Set-Content -LiteralPath $source -Value '{"setting":"source"}' + Set-Content -LiteralPath $destination -Value '{"setting":"destination"}' + Set-Content -LiteralPath (Join-Path $TestDrive 'Settings1.json') -Value '{"setting":"wrong"}' + + $modifiedContent = GetModifiedSettingsContent -srcSettingsFile $source -dstSettingsFile $destination | ConvertFrom-Json + + $modifiedContent.setting | Should -Be 'destination' + } + It 'GetModifiedSettingsContent returns correct content when destination file is empty' { # Create only the source file @{ "`$schema" = "someSchema"; "srcSetting" = "value1" } | ConvertTo-Json -Depth 10 | Out-File -FilePath $tmpSrcFile -Force @@ -449,6 +957,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") } @@ -1479,6 +1988,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 @@ -1624,29 +2194,140 @@ Describe "ResolveFilePaths" { $fullFilePaths[4].type | Should -Be "markdown" } - It 'ResolveFilePaths skips files outside the source folder' { - # Create an external file outside the source folder + It 'ResolveFilePaths treats brackets in file sourceFolder literally' { + $bracketFolder = Join-Path $sourceFolder 'folder[1]' + $bracketFile = Join-Path $bracketFolder 'File.txt' + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + try { + New-Item -Path $bracketFolder -ItemType Directory -Force | Out-Null + Set-Content -LiteralPath $bracketFile -Value 'literal folder' + + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -destinationFolder $destinationFolder -files @(@{ sourceFolder = 'folder[1]'; filter = '*.txt' })) + + $fullFilePaths.Count | Should -Be 1 + $fullFilePaths[0].sourceFullPath | Should -Be $bracketFile + $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder 'folder[1]/File.txt') + } + finally { + Remove-Item -LiteralPath $bracketFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'ResolveFilePaths skips source files lexically escaping 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 source files lexically escaping into a 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 source files when its original source file lexically escapes the original source folder' { + $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" + $escapedOriginalSourceFile = Resolve-PathLexically -Path (Join-Path $originalSourceFolder "folder/File1.txt") + + # Test-PathLexicallyContained already guarantees this can't happen through legitimate inputs (both sides are + # always resolved consistently), so force just this one call to fail to exercise the defensive "else" branch. + $realTestPathLexicallyContained = (Get-Item function:Test-PathLexicallyContained).ScriptBlock + Mock Test-PathLexicallyContained { + if ($Path -eq $escapedOriginalSourceFile -and $RootFolder -eq $originalSourceFolder) { + return $false + } + & $realTestPathLexicallyContained -Path $Path -RootFolder $RootFolder + } + Mock OutputWarning {} + + $files = @(@{ "sourceFolder" = "folder"; "filter" = "File1.txt" }) + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -originalSourceFolder $originalSourceFolder) + + # The file must be skipped entirely, not merely left with originalSourceFullPath = $null + $fullFilePaths | Where-Object { $_.sourceFullPath -eq (Join-Path $sourceFolder "folder/File1.txt") } | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*is not under the original source folder*" } + } + + It 'ResolveFilePaths skips source files lexically escaping to folder that differs only by case on Linux' -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 lexically escaping to folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' $files = @( - @{ "sourceFolder" = "../external"; "filter" = "*.txt" } + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = 'folder'; 'destinationName' = '../FOLDER/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 + } - # Ensure none of the returned sourceFullPath entries point to the external file - $fullFilePaths | ForEach-Object { $_.sourceFullPath | Should -Not -Be $externalFile } + It 'ResolveFilePaths skips per-project destinations lexically escaping to folder that differs only by case on Linux' -Skip:(-not $script:isLinuxPlatform) { + $destinationFolder = Join-Path $rootFolder 'destinationFolder' + $files = @( + @{ 'sourceFolder' = 'folder'; 'filter' = 'File1.txt'; 'destinationFolder' = ''; 'destinationName' = '../PROJECT/outside.txt'; 'perProject' = $true } + ) + Mock OutputWarning {} - # Cleanup - if (Test-Path $externalFile) { Remove-Item -Path $externalFile -Force } - if (Test-Path $externalFolder) { Remove-Item -Path $externalFolder -Recurse -Force } + $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @('project')) + + $fullFilePaths | Should -BeNullOrEmpty + Should -Invoke OutputWarning -Times 1 } It 'ResolveFilePaths returns empty when no files match filter' { @@ -1702,7 +2383,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") } - It 'ResolveFilePaths avoids duplicate destination entries' { + It 'ResolveFilePaths retains duplicate destination candidates' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @{ "sourceFolder" = "folder"; "filter" = "File1.txt" } @@ -1712,8 +2393,9 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) $fullFilePaths | Should -Not -BeNullOrEmpty - $fullFilePaths.Count | Should -Be 1 + $fullFilePaths.Count | Should -Be 2 $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "folder/File1.txt") + $fullFilePaths[1].destinationFullPath | Should -Be $fullFilePaths[0].destinationFullPath } It 'ResolveFilePaths treats dot project as repository root for per-project files' { @@ -1853,7 +2535,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[1].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File3.txt") } - It 'ResolveFilePaths with perProject skips duplicate files across projects' { + It 'ResolveFilePaths with perProject retains duplicate destination candidates' { $destinationFolder = Join-Path $PSScriptRoot "destinationFolder" $files = @( @{ "sourceFolder" = "folder"; "filter" = "File1.txt"; perProject = $true } @@ -1862,10 +2544,10 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder -projects @("ProjectA")) - # Should only have one entry per project since both resolve to the same destination $fullFilePaths | Should -Not -BeNullOrEmpty - $fullFilePaths.Count | Should -Be 1 + $fullFilePaths.Count | Should -Be 2 $fullFilePaths[0].destinationFullPath | Should -Be (Join-Path $destinationFolder "ProjectA/folder/File1.txt") + $fullFilePaths[1].destinationFullPath | Should -Be $fullFilePaths[0].destinationFullPath } It 'ResolveFilePaths handles empty sourceFolder value' { @@ -1900,10 +2582,9 @@ Describe "ResolveFilePaths" { $fullFilePaths = @(ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder) - # File1.txt should only appear once even though both filters match it $fullFilePaths | Should -Not -BeNullOrEmpty $file1Matches = @($fullFilePaths | Where-Object { $_.sourceFullPath -eq (Join-Path $sourceFolder "folder/File1.txt") }) - $file1Matches.Count | Should -Be 1 + $file1Matches.Count | Should -Be 2 } It 'ResolveFilePaths correctly resolves originalSourceFullPath only when file exists in original folder' { @@ -1938,6 +2619,28 @@ Describe "ResolveFilePaths" { } } + 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 = @( @@ -1954,7 +2657,7 @@ Describe "ResolveFilePaths" { $fullFilePaths[0].sourceFullPath | Should -Be (Join-Path $sourceFolder "folder/File2.log") } - It 'ResolveFilePaths handles case-insensitive filter matching on Windows' { + It 'ResolveFilePaths handles case-insensitive filter matching' { # Create files with different case $upperFile = Join-Path $sourceFolder "folder/UPPER.TXT" $lowerFile = Join-Path $sourceFolder "folder/lower.txt" @@ -1969,10 +2672,9 @@ Describe "ResolveFilePaths" { $fullFilePaths = ResolveFilePaths -sourceFolder $sourceFolder -files $files -destinationFolder $destinationFolder - # On Windows, both should match due to case-insensitive file system $fullFilePaths | Should -Not -BeNullOrEmpty - $upperMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -eq $upperFile } - $lowerMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -eq $lowerFile } + $upperMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -ceq $upperFile } + $lowerMatch = $fullFilePaths | Where-Object { $_.sourceFullPath -ceq $lowerFile } $upperMatch | Should -Not -BeNullOrEmpty $lowerMatch | Should -Not -BeNullOrEmpty } @@ -1983,45 +2685,626 @@ Describe "ResolveFilePaths" { } } -Describe "ReplaceOwnerRepoAndBranch" { +Describe "Resolve-PathLexically" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "resolvePathLexicallyTests" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null } - It "Replaces owner, repo, and branch in workflow content" { - $srcContent = [ref]@" -jobs: - build: - uses: microsoft/AL-Go-Actions@main -"@ - $templateOwner = "contoso" - $templateBranch = "dev" - ReplaceOwnerRepoAndBranch -srcContent $srcContent -templateOwner $templateOwner -templateBranch $templateBranch - $srcContent.Value | Should -Be @" -jobs: - build: - uses: contoso/AL-Go/Actions@dev -"@ + AfterAll { + Remove-Item -Path $rootFolder -Recurse -Force -ErrorAction SilentlyContinue } -} -Describe "IsDirectALGo" { - BeforeAll { - $actionName = "CheckForUpdates" - $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve - . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + It 'Resolve-PathLexically returns an already-rooted path unchanged' { + $path = Join-Path $rootFolder "folder/file.txt" + Resolve-PathLexically -Path $path | Should -Be ([System.IO.Path]::GetFullPath($path)) } - It "Returns true for direct AL-Go repo URL" { - IsDirectALGo -templateUrl "https://github.com/contoso/AL-Go@main" | Should -Be True + + It 'Resolve-PathLexically resolves ".." and "." segments in a rooted path' { + $path = Join-Path $rootFolder "folder/../folder2/./file.txt" + Resolve-PathLexically -Path $path | Should -Be (Join-Path $rootFolder "folder2/file.txt") } - It "Returns false for non-direct AL-Go repo URL" { - IsDirectALGo -templateUrl "https://github.com/contoso/OtherRepo@main" | Should -Be False + + It 'Resolve-PathLexically resolves a relative path against the current location' { + Push-Location -Path $rootFolder + try { + Resolve-PathLexically -Path "sub/file.txt" | Should -Be (Join-Path $rootFolder "sub/file.txt") + } + finally { + Pop-Location + } + } + + It 'Resolve-PathLexically with -AsDirectory ensures a trailing directory separator' { + $path = Join-Path $rootFolder "folder" + Resolve-PathLexically -Path $path -AsDirectory | Should -Be (Join-Path ([System.IO.Path]::GetFullPath($path)) '') + } + + It 'Resolve-PathLexically with -AsDirectory does not duplicate an existing trailing directory separator' { + $path = Join-Path (Join-Path $rootFolder "folder") '' + Resolve-PathLexically -Path $path -AsDirectory | Should -Be $path + } + + It 'Resolve-PathLexically without -AsDirectory does not add a trailing directory separator' { + $path = Join-Path $rootFolder "folder" + Resolve-PathLexically -Path $path | Should -Be ([System.IO.Path]::GetFullPath($path)) } } -Describe "GetFilesToUpdate (general files to update logic)" { +Describe "Test-PathLexicallyContained" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "lexicallyContainedTests" + $externalFolder = Join-Path $PSScriptRoot "lexicallyContainedTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Test-PathLexicallyContained returns true for a path lexically inside the root folder' { + $path = Join-Path $rootFolder "folder/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathLexicallyContained returns false for a path lexically outside the root folder' { + $path = Join-Path $externalFolder "file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + + It 'Test-PathLexicallyContained returns true for a path that stays inside the root folder after resolving ".." segments' { + $path = Join-Path $rootFolder "folder/../folder2/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathLexicallyContained returns false for a path that escapes the root folder via ".." segments' { + $path = Join-Path $rootFolder "folder/../../outside.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $false + } + + It 'Test-PathLexicallyContained returns true for a path with "." segments that stays inside the root folder' { + $path = Join-Path $rootFolder "./folder/./file.txt" + Test-PathLexicallyContained -Path $path -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathLexicallyContained returns true when Path equals RootFolder exactly' { + Test-PathLexicallyContained -Path $rootFolder -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathLexicallyContained returns true when Path equals RootFolder with a trailing separator on either side' { + $rootFolderWithSlash = Join-Path $rootFolder '' + Test-PathLexicallyContained -Path $rootFolder -RootFolder $rootFolderWithSlash | Should -Be $true + Test-PathLexicallyContained -Path $rootFolderWithSlash -RootFolder $rootFolder | Should -Be $true + } + + It 'Test-PathLexicallyContained is case-insensitive on Windows' -Skip:(-not $script:isWindowsPlatform) { + $path = Join-Path $rootFolder "FOLDER/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder (Join-Path $rootFolder "folder") | Should -Be $true + } + + It 'Test-PathLexicallyContained is case-sensitive on Linux' -Skip:(-not $script:isLinuxPlatform) { + $path = Join-Path $rootFolder "FOLDER/file.txt" + Test-PathLexicallyContained -Path $path -RootFolder (Join-Path $rootFolder "folder") | Should -Be $false + } +} + +Describe "Resolve-PathPhysically" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "resolvePhysicalPathTests" + $externalFolder = Join-Path $PSScriptRoot "resolvePhysicalPathTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Resolve-PathPhysically returns the canonicalized path when there are no reparse points' { + $path = Join-Path $rootFolder "folder/file.txt" + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + + It 'Resolve-PathPhysically fails closed when a path segment cannot be inspected' { + $blockedPath = Join-Path $rootFolder 'blocked' + $path = Join-Path $blockedPath 'file.txt' + Mock Get-Item { throw [System.UnauthorizedAccessException]::new('Access denied') } -ParameterFilter { $LiteralPath -eq $blockedPath } + Mock OutputWarning {} + + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be $null + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false + Should -Invoke OutputWarning -Times 2 -ParameterFilter { $message -like '*unable to inspect*' } + } + + It 'Resolve-PathPhysically returns a folder anchor unchanged when Path equals the anchor exactly' { + $folder = Join-Path $rootFolder "exactFolderAnchor" + New-Item -Path $folder -ItemType Directory -Force | Out-Null + Resolve-PathPhysically -Path $folder -AnchorPaths @($folder) | Should -Be ([System.IO.Path]::GetFullPath($folder)) + } + + It 'Resolve-PathPhysically returns a file anchor unchanged when Path equals the anchor exactly' { + $file = Join-Path $rootFolder "exactFileAnchor.txt" + Resolve-PathPhysically -Path $file -AnchorPaths @($file) | Should -Be ([System.IO.Path]::GetFullPath($file)) + } + + It 'Resolve-PathPhysically resolves a relative Path against the current location' { + $subFolder = Join-Path $rootFolder "relativePathInput" + New-Item -Path $subFolder -ItemType Directory -Force | Out-Null + Push-Location -Path $subFolder + try { + Resolve-PathPhysically -Path "file.txt" | Should -Be (Join-Path $subFolder "file.txt") + } + finally { + Pop-Location + Remove-Item -Path $subFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a single symlink to its real target' -Skip:(-not $script:hasSymlinkCapability) { + $realTargetFolder = Join-Path $rootFolder "singleSymRealTarget" + $linkedFolder = Join-Path $rootFolder "singleSymLink" + $path = Join-Path $linkedFolder "file.txt" + try { + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + + $resolved = Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) + + $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") + $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a single junction to its real target' -Skip:(-not $script:isWindowsPlatform) { + $realTargetFolder = Join-Path $rootFolder "singleJctRealTarget" + $linkedFolder = Join-Path $rootFolder "singleJctLink" + $path = Join-Path $linkedFolder "file.txt" + try { + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + + $resolved = Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) + + $resolved | Should -Be (Join-Path $realTargetFolder "file.txt") + $resolved | Should -Not -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a chain of two symlinks (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:hasSymlinkCapability) { + $realFolder = Join-Path $rootFolder "chainSymRealTarget" + $link2 = Join-Path $rootFolder "chainSymLink2" + $link1 = Join-Path $rootFolder "chainSymLink1" + $path = Join-Path $link1 "file.txt" + try { + New-Item -Path $realFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $link2 -Target $realFolder -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $link1 -Target $link2 -Force | Out-Null + + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") + } + finally { + Remove-Item -Path $link1 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $link2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a chain of two junctions (link1 -> link2 -> real folder) to its real target' -Skip:(-not $script:isWindowsPlatform) { + $realFolder = Join-Path $rootFolder "chainJctRealTarget" + $link2 = Join-Path $rootFolder "chainJctLink2" + $link1 = Join-Path $rootFolder "chainJctLink1" + $path = Join-Path $link1 "file.txt" + try { + New-Item -Path $realFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $link2 -Target $realFolder -Force | Out-Null + New-Item -ItemType Junction -Path $link1 -Target $link2 -Force | Out-Null + + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "file.txt") + } + finally { + Remove-Item -Path $link1 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $link2 -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a dangling symlink to its real target without warning' -Skip:(-not $script:hasSymlinkCapability) { + $danglingTarget = Join-Path $rootFolder "danglingSymTarget" + $linkPath = Join-Path $rootFolder "danglingSymLink" + try { + New-Item -Path $danglingTarget -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkPath -Target $danglingTarget -Force | Out-Null + Remove-Item -Path $danglingTarget -Force + Mock OutputWarning {} + + Resolve-PathPhysically -Path $linkPath -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) + Should -Invoke OutputWarning -Times 0 + } + finally { + Remove-Item -Path $linkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $danglingTarget -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a dangling junction to its real target without warning' -Skip:(-not $script:isWindowsPlatform) { + $danglingTarget = Join-Path $rootFolder "danglingJctTarget" + $linkPath = Join-Path $rootFolder "danglingJctLink" + try { + New-Item -Path $danglingTarget -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $linkPath -Target $danglingTarget -Force | Out-Null + Remove-Item -Path $danglingTarget -Force + Mock OutputWarning {} + + Resolve-PathPhysically -Path $linkPath -AnchorPaths @($rootFolder) | Should -Be ([System.IO.Path]::GetFullPath($danglingTarget)) + Should -Invoke OutputWarning -Times 0 + } + finally { + Remove-Item -Path $linkPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $danglingTarget -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically returns $null and warns when a symlink chain cycles back on itself' -Skip:(-not $script:hasSymlinkCapability) { + $linkA = Join-Path $rootFolder "cyclicLinkA" + $linkB = Join-Path $rootFolder "cyclicLinkB" + try { + New-Item -Path $linkA -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkB -Target $linkA -Force | Out-Null + Remove-Item -Path $linkA -Force + New-Item -ItemType SymbolicLink -Path $linkA -Target $linkB -Force | Out-Null + Mock OutputWarning {} + + Resolve-PathPhysically -Path $linkA -AnchorPaths @($rootFolder) | Should -Be $null + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*exceeded*hops*" } + } + finally { + Remove-Item -Path $linkA -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $linkB -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically returns $null and warns when a junction chain cycles back on itself' -Skip:(-not $script:isWindowsPlatform) { + $nodeA = Join-Path $rootFolder "cyclicJctNodeA" + $nodeB = Join-Path $rootFolder "cyclicJctNodeB" + try { + New-Item -Path $nodeA -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $nodeB -Target $nodeA -Force | Out-Null + Remove-Item -Path $nodeA -Force + New-Item -ItemType Junction -Path $nodeA -Target $nodeB -Force | Out-Null + Mock OutputWarning {} + + Resolve-PathPhysically -Path $nodeA -AnchorPaths @($rootFolder) | Should -Be $null + Should -Invoke OutputWarning -Times 1 -ParameterFilter { $message -like "*exceeded*hops*" } + } + finally { + Remove-Item -Path $nodeA -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $nodeB -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a symlink whose target path has a parent symlink (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:hasSymlinkCapability) { + $realFolder = Join-Path $rootFolder "nestedSymRealTarget" + $parentLink = Join-Path $rootFolder "nestedSymParentLink" + $outerLink = Join-Path $rootFolder "nestedSymOuterLink" + $path = Join-Path $outerLink "file.txt" + try { + New-Item -Path (Join-Path $realFolder "subdir") -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $parentLink -Target $realFolder -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $outerLink -Target (Join-Path $parentLink "subdir") -Force | Out-Null + + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") + } + finally { + Remove-Item -Path $outerLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $parentLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically resolves a junction whose target path has a parent junction (link1 -> link2/subdir/file.txt)' -Skip:(-not $script:isWindowsPlatform) { + $realFolder = Join-Path $rootFolder "nestedJctRealTarget" + $parentLink = Join-Path $rootFolder "nestedJctParentLink" + $outerLink = Join-Path $rootFolder "nestedJctOuterLink" + $path = Join-Path $outerLink "file.txt" + try { + New-Item -Path (Join-Path $realFolder "subdir") -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $parentLink -Target $realFolder -Force | Out-Null + New-Item -ItemType Junction -Path $outerLink -Target (Join-Path $parentLink "subdir") -Force | Out-Null + + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $realFolder "subdir/file.txt") + } + finally { + Remove-Item -Path $outerLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $parentLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically trusts a symlink when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PathPhysically -Path $path -AnchorPaths @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically trusts a junction when its own path is passed as an anchor folder, skipping resolution' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder) | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PathPhysically -Path $path -AnchorPaths @($trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically trusts a symlink when its own path is passed as a relative anchor folder, skipping resolution' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + Push-Location $rootFolder + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @(".") | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PathPhysically -Path $path -AnchorPaths @(Split-Path $trustedLink -Leaf) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Pop-Location + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically trusts a junction when its own path is passed as a relative anchor folder, skipping resolution' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + Push-Location $rootFolder + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # Without the link in AnchorPaths, it is followed to its real (external) target + Resolve-PathPhysically -Path $path -AnchorPaths @(".") | Should -Be (Join-Path $externalTargetFolder "file.txt") + + # When the link's own path is passed as an anchor, it is trusted and not followed + Resolve-PathPhysically -Path $path -AnchorPaths @(Split-Path $trustedLink -Leaf) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Pop-Location + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically picks the most specific of multiple anchors, trusting a symlink sitting at the deeper anchor' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "multiAnchorSymTarget" + $trustedLink = Join-Path $rootFolder "multiAnchorSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # A broader anchor (rootFolder) alone would not bypass resolution, but the more specific + # anchor (the link itself) is selected and trusted, even when both are supplied together + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Resolve-PathPhysically picks the most specific of multiple anchors, trusting a junction sitting at the deeper anchor' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "multiAnchorJctTarget" + $trustedLink = Join-Path $rootFolder "multiAnchorJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + # A broader anchor (rootFolder) alone would not bypass resolution, but the more specific + # anchor (the link itself) is selected and trusted, even when both are supplied together + Resolve-PathPhysically -Path $path -AnchorPaths @($rootFolder, $trustedLink) | Should -Be ([System.IO.Path]::GetFullPath($path)) + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe "Test-PathPhysicallyEqual" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + + $rootFolder = Join-Path $PSScriptRoot "physicallyEqualTests" + $externalFolder = Join-Path $PSScriptRoot "physicallyEqualTestsExternal" + New-Item -Path $rootFolder -ItemType Directory -Force | Out-Null + New-Item -Path $externalFolder -ItemType Directory -Force | Out-Null + } + + AfterAll { + Remove-Item -Path $rootFolder, $externalFolder -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Test-PathPhysicallyEqual delegates to Resolve-PathPhysically and compares its result against the canonicalized path' { + $path = Join-Path $rootFolder "delegationCheck/file.txt" + + Mock Resolve-PathPhysically { return $Path } + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $true + Should -Invoke Resolve-PathPhysically -Times 1 -ParameterFilter { + $Path -eq ([System.IO.Path]::GetFullPath($path)) -and (Compare-Object $AnchorPaths @($rootFolder) | Measure-Object).Count -eq 0 + } + + Mock Resolve-PathPhysically { return (Join-Path $rootFolder "somewhereElse/file.txt") } + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false + } + + It 'Test-PathPhysicallyEqual returns true when there are no reparse points' { + $path = Join-Path $rootFolder "folder/file.txt" + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $true + } + + It 'Test-PathPhysicallyEqual returns true when the path does not exist yet' { + $path = Join-Path $rootFolder "doesNotExist/file.txt" + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $true + } + + It 'Test-PathPhysicallyEqual returns false when a symlink redirects to a different real location' -Skip:(-not $script:hasSymlinkCapability) { + $realTargetFolder = Join-Path $rootFolder "redirectSymTarget" + $linkedFolder = Join-Path $rootFolder "redirectSymLink" + $path = Join-Path $linkedFolder "file.txt" + try { + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false + } + finally { + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyEqual returns false when a junction redirects to a different real location' -Skip:(-not $script:isWindowsPlatform) { + $realTargetFolder = Join-Path $rootFolder "redirectJctTarget" + $linkedFolder = Join-Path $rootFolder "redirectJctLink" + $path = Join-Path $linkedFolder "file.txt" + try { + New-Item -Path $realTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $linkedFolder -Target $realTargetFolder -Force | Out-Null + + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($rootFolder) | Should -Be $false + } + finally { + Remove-Item -Path $linkedFolder -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $realTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyEqual returns true when the anchor folder is itself a symlink, bypassing resolution' -Skip:(-not $script:hasSymlinkCapability) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustSymTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustSymLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType SymbolicLink -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($trustedLink) | Should -Be $true + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'Test-PathPhysicallyEqual returns true when the anchor folder is itself a junction, bypassing resolution' -Skip:(-not $script:isWindowsPlatform) { + $externalTargetFolder = Join-Path $externalFolder "anchorTrustJctTarget" + $trustedLink = Join-Path $rootFolder "anchorTrustJctLink" + $path = Join-Path $trustedLink "file.txt" + try { + New-Item -Path $externalTargetFolder -ItemType Directory -Force | Out-Null + New-Item -ItemType Junction -Path $trustedLink -Target $externalTargetFolder -Force | Out-Null + + Test-PathPhysicallyEqual -Path $path -AnchorPaths @($trustedLink) | Should -Be $true + } + finally { + Remove-Item -Path $trustedLink -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $externalTargetFolder -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe "ReplaceOwnerRepoAndBranch" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + } + + It "Replaces owner, repo, and branch in workflow content" { + $srcContent = [ref]@" +jobs: + build: + uses: microsoft/AL-Go-Actions@main +"@ + $templateOwner = "contoso" + $templateBranch = "dev" + ReplaceOwnerRepoAndBranch -srcContent $srcContent -templateOwner $templateOwner -templateBranch $templateBranch + $srcContent.Value | Should -Be @" +jobs: + build: + uses: contoso/AL-Go/Actions@dev +"@ + } +} + +Describe "IsDirectALGo" { + BeforeAll { + $actionName = "CheckForUpdates" + $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve + . (Join-Path -Path $scriptRoot -ChildPath "CheckForUpdates.HelperFunctions.ps1") + } + It "Returns true for direct AL-Go repo URL" { + IsDirectALGo -templateUrl "https://github.com/contoso/AL-Go@main" | Should -Be True + } + It "Returns false for non-direct AL-Go repo URL" { + IsDirectALGo -templateUrl "https://github.com/contoso/OtherRepo@main" | Should -Be False + } +} + +Describe "GetFilesToUpdate (general files to update logic)" { BeforeAll { $actionName = "CheckForUpdates" $scriptRoot = Join-Path $PSScriptRoot "..\Actions\$actionName" -Resolve @@ -2031,437 +3314,1163 @@ Describe "GetFilesToUpdate (general files to update logic)" { $templateFolder = Join-Path $PSScriptRoot "template" New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $templateFolder "subfolder") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $templateFolder "subfolder") -Force | Out-Null + + $testPSFile = Join-Path $templateFolder "test.ps1" + Set-Content -Path $testPSFile -Value "# test ps file" + + $testTxtFile = Join-Path $templateFolder "test.txt" + Set-Content -Path $testTxtFile -Value "test txt file" + + $testTxtFile2 = Join-Path $templateFolder "test2.txt" + Set-Content -Path $testTxtFile2 -Value "test txt file 2" + + $testSubfolderFile = Join-Path $templateFolder "subfolder/testsub.txt" + Set-Content -Path $testSubfolderFile -Value "test subfolder txt file" + + $testSubfolderFile2 = Join-Path $templateFolder "subfolder/testsub2.txt" + Set-Content -Path $testSubfolderFile2 -Value "test subfolder txt file 2" + + # Display the created files structure for template folder + # . + # ├── test.ps1 + # ├── test.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 + # └── 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" { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.ps1" }) + filesToExclude = @() + } + } + + $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') + + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @() + } + } + + $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[1].sourceFullPath | Should -Be $testTxtFile2 + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') + + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty + } + + It 'Returns the correct files with destinationFolder' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) + filesToExclude = @() + } + } + + $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[1].sourceFullPath | Should -Be $testTxtFile2 + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'customFolder/test2.txt') + + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) + filesToExclude = @(@{ filter = "test2.txt" }) + } + } + + $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') + + # 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 'customFolder/test2.txt') + } + + It 'Returns the correct files with destinationName' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1"; destinationName = "renamed.txt" }) + filesToExclude = @() + } + } + + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - $testPSFile = Join-Path $templateFolder "test.ps1" - Set-Content -Path $testPSFile -Value "# test ps file" + $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') - $testTxtFile = Join-Path $templateFolder "test.txt" - Set-Content -Path $testTxtFile -Value "test txt file" + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty - $testTxtFile2 = Join-Path $templateFolder "test2.txt" - Set-Content -Path $testTxtFile2 -Value "test txt file 2" + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "test.ps1"; destinationFolder = 'dstPath'; destinationName = "renamed.txt" }) + filesToExclude = @() + } + } - $testSubfolderFile = Join-Path $templateFolder "subfolder/testsub.txt" - Set-Content -Path $testSubfolderFile -Value "test subfolder txt file" + $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') + } + + It 'Return the correct files with types' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.ps1"; type = "script" }) + filesToExclude = @() + } + } + + $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].type | Should -Be "script" + + # No files to exclude or remove + $filesToExclude | Should -BeNullOrEmpty + + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; type = "text" }) + filesToExclude = @(@{ filter = "test.txt" }) + } + } + + $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].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') + } + + It 'Return the correct files when unusedALGoSystemFiles is specified' { + $settings = @{ + type = "nonPTE" + unusedALGoSystemFiles = @("test.ps1") + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*" }) + filesToExclude = @() + } + } + + $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[1].sourceFullPath | Should -Be $testTxtFile2 + $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') + } + + It 'GetFilesToUpdate with perProject true and empty projects returns no per-project entries' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt"; type = "text"; perProject = $true }) + filesToExclude = @() + } + } + + # Pass empty projects array + $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 + $filesToExclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate ignores filesToExclude patterns that do not match any file' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @(@{ filter = "no-match-*.none" }) + } + } + + $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[1].sourceFullPath | Should -Be $testTxtFile2 + $filesToInclude[1].destinationFullPath | Should -Be (Join-Path $baseFolder 'test2.txt') + + $filesToExclude | Should -BeNullOrEmpty + } + + It 'GetFilesToUpdate duplicates per-project includes for each project including the repository root' { + $perProjectFile = Join-Path $templateFolder "perProjectFile.algo" + Set-Content -Path $perProjectFile -Value "per project" + + try { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "perProjectFile.algo"; perProject = $true; destinationFolder = 'custom' }) + filesToExclude = @() + } + } + + $projects = @('.', 'ProjectOne') + $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' + + $filesToInclude.destinationFullPath | Should -Contain $rootDestination + $filesToInclude.destinationFullPath | Should -Contain $projectDestination + + $filesToExclude | Should -BeNullOrEmpty + } + finally { + if (Test-Path $perProjectFile) { + Remove-Item -Path $perProjectFile -Force + } + } + } + + It 'GetFilesToUpdate adds custom template settings only when original template folder is provided' { + $customTemplateFolder = Join-Path $PSScriptRoot "customTemplateFolder" + $originalTemplateFolder = Join-Path $PSScriptRoot "originalTemplateFolder" + + New-Item -ItemType Directory -Path $customTemplateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $customTemplateFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $customTemplateFolder '.AL-Go') -Force | Out-Null + Set-Content -Path (Join-Path $customTemplateFolder (Join-Path '.github' $RepoSettingsFileName)) -Value '{}' -Encoding UTF8 + Set-Content -Path (Join-Path $customTemplateFolder (Join-Path '.AL-Go' $ALGoSettingsFileName)) -Value '{}' -Encoding UTF8 + + New-Item -ItemType Directory -Path $originalTemplateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $originalTemplateFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $originalTemplateFolder '.AL-Go') -Force | Out-Null + Set-Content -Path (Join-Path $originalTemplateFolder (Join-Path '.github' $RepoSettingsFileName)) -Value '{"original":true}' -Encoding UTF8 + Set-Content -Path (Join-Path $originalTemplateFolder (Join-Path '.AL-Go' $ALGoSettingsFileName)) -Value '{"original":true}' -Encoding UTF8 + + try { + $settings = @{ + type = "PTE" + powerPlatformSolutionFolder = '' + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @() + filesToExclude = @() + } + } + + $projects = @('ProjectA') + + $filesWithoutOriginal, $excludesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects + + $filesWithoutOriginal | Should -Not -BeNullOrEmpty + + $repoSettingsDestination = Join-Path $baseFolder (Join-Path '.github' $RepoSettingsFileName) + $projectSettingsRelative = Join-Path 'ProjectA' '.AL-Go' + $projectSettingsRelative = Join-Path $projectSettingsRelative $ALGoSettingsFileName + $projectSettingsDestination = Join-Path $baseFolder $projectSettingsRelative + + $filesWithoutOriginal.destinationFullPath | Should -Contain $repoSettingsDestination + $filesWithoutOriginal.destinationFullPath | Should -Contain $projectSettingsDestination + $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) + $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) + + $filesWithOriginal, $excludesWithOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder -projects $projects + + $filesWithOriginal | Should -Not -BeNullOrEmpty + + $filesWithOriginal.destinationFullPath | Should -Contain $repoSettingsDestination + $filesWithOriginal.destinationFullPath | Should -Contain $projectSettingsDestination + $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) + $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) + + $excludesWithoutOriginal | Should -BeNullOrEmpty + + $excludesWithOriginal | Should -BeNullOrEmpty + } + finally { + if (Test-Path $customTemplateFolder) { + Remove-Item -Path $customTemplateFolder -Recurse -Force + } + if (Test-Path $originalTemplateFolder) { + Remove-Item -Path $originalTemplateFolder -Recurse -Force + } + } + } + + It 'GetFilesToUpdate excludes files that match both include and exclude patterns' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) + } + } + + $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") } + $includedTestTxt | Should -BeNullOrEmpty + + # test.txt should be in filesToExclude + $excludedTestTxt = $filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } + $excludedTestTxt | Should -Not -BeNullOrEmpty + } + + It 'GetFilesToUpdate ignores exclude patterns that do not match any included file' { + $settings = @{ + type = "NotPTE" + unusedALGoSystemFiles = @() + customALGoFiles = @{ + filesToInclude = @(@{ filter = "*.txt" }) + filesToExclude = @(@{ filter = "nonexistent.xyz" }) + } + } - $testSubfolderFile2 = Join-Path $templateFolder "subfolder/testsub2.txt" - Set-Content -Path $testSubfolderFile2 -Value "test subfolder txt file 2" + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - # Display the created files structure for template folder - # . - # ├── test.ps1 - # ├── test.txt - # └── test2.txt - # └── subfolder - # └── testsub.txt - } + # All txt files should be included + $filesToInclude | Should -Not -BeNullOrEmpty + $txtFiles = $filesToInclude | Where-Object { $_.sourceFullPath -like "*.txt" } + $txtFiles.Count | Should -BeGreaterThan 0 - AfterAll { - if (Test-Path $templateFolder) { - Remove-Item -Path $templateFolder -Recurse -Force - } + # Exclude list should not contain the non-matching pattern + $excludedNonExistent = $filesToExclude | Where-Object { $_.sourceFullPath -like "*.xyz" } + $excludedNonExistent | Should -BeNullOrEmpty } - It "Returns the correct files to update with filters" { + It 'GetFilesToUpdate excludes files with different destinations that match both include and exclude patterns' { $settings = @{ type = "NotPTE" unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.ps1" }) - filesToExclude = @() + filesToInclude = @(@{ filter = "test.txt" }, @{ filter = "test.txt"; destinationName = "test.renamed.txt" }) + filesToExclude = @(@{ filter = "test.txt" }) } } - $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') + # test.txt should not be in filesToInclude + $filesToInclude | Should -BeNullOrEmpty - # No files to remove - $filesToExclude | 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" unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt" }) + filesToInclude = @( + @{ filter = "test.txt"; destinationFolder = "folder1" } + @{ filter = "test.txt"; destinationFolder = "folder2" } + ) filesToExclude = @() } } - $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[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - # No files to remove - $filesToExclude | Should -BeNullOrEmpty + # 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') } - It 'Returns the correct files with destinationFolder' { + It 'GetFilesToUpdate filesToInclude keeps the first entry when two entries collide on the same destination' { $settings = @{ type = "NotPTE" unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) + filesToInclude = @(@{ filter = "test.ps1"; destinationName = "conflict.txt" }, @{ filter = "test.txt"; destinationName = "conflict.txt" }) filesToExclude = @() } } - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + Mock OutputDebug { } + $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[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'customFolder/test2.txt') + # 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 - # No files to remove - $filesToExclude | Should -BeNullOrEmpty + # The first-listed entry should win over the later entry for the same destination + $conflict[0].sourceFullPath | Should -Be $testPSFile + Should -Invoke OutputDebug -Exactly -Times 1 -ParameterFilter { $message -eq "Skipping duplicate file to include '$testTxtFile': destinationFullPath '$(Join-Path $baseFolder 'conflict.txt')' already included" } + } + It 'GetFilesToUpdate filesToExclude keeps the first entry when two entries collide on the same destination' { $settings = @{ - type = "NotPTE" + type = 'NotPTE' unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt"; destinationFolder = "customFolder" }) - filesToExclude = @(@{ filter = "test2.txt" }) + filesToInclude = @(@{ filter = 'test.ps1'; destinationName = 'conflict.txt' }, @{ filter = 'test.txt'; destinationName = 'conflict.txt' }) + filesToExclude = @(@{ filter = 'test.ps1' }, @{ filter = 'test.txt' }) } } - $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') + Mock OutputDebug { } + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder - # One file to remove - $filesToExclude | Should -Not -BeNullOrEmpty + $filesToInclude | Should -BeNullOrEmpty $filesToExclude.Count | Should -Be 1 - $filesToExclude[0].sourceFullPath | Should -Be $testTxtFile2 - $filesToExclude[0].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $filesToExclude[0].sourceFullPath | Should -Be $testPSFile + $filesToExclude[0].destinationFullPath | Should -Be (Join-Path $baseFolder 'conflict.txt') + Should -Invoke OutputDebug -Exactly -Times 1 -ParameterFilter { $message -eq "Skipping duplicate file to exclude '$testTxtFile': destinationFullPath '$(Join-Path $baseFolder 'conflict.txt')' already excluded" } } - It 'Returns the correct files with destinationName' { + It 'GetFilesToUpdate retains a replacement when the first source for a destination is excluded' { $settings = @{ - type = "NotPTE" + type = 'NotPTE' unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.ps1"; destinationName = "renamed.txt" }) + filesToInclude = @(@{ filter = 'test.ps1'; destinationName = 'conflict.txt' }, @{ filter = 'test.txt'; destinationName = 'conflict.txt' }) + filesToExclude = @(@{ filter = 'test.ps1' }) + } + } + + Mock OutputDebug { } + $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $templateFolder + + $conflict = @($filesToInclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder 'conflict.txt') }) + $conflict.Count | Should -Be 1 + $conflict[0].sourceFullPath | Should -Be (Join-Path $templateFolder 'test.txt') + @($filesToExclude | Where-Object { $_.destinationFullPath -eq (Join-Path $baseFolder 'conflict.txt') }).Count | Should -Be 0 + Should -Invoke OutputDebug -Exactly -Times 1 -ParameterFilter { $message -eq "Skipping file to exclude '$testPSFile': destinationFullPath '$(Join-Path $baseFolder 'conflict.txt')' included with different source" } + } + + It 'GetFilesToUpdate keeps case-distinct destination entries on Linux' -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, $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.destinationFullPath -ccontains (Join-Path $baseFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeTrue + } - # No files to remove - $filesToExclude | Should -BeNullOrEmpty + It 'GetFilesToUpdate excludes only the exact-case source path on Linux' -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 Linux' -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 Windows' -Skip:(-not $script:isWindowsPlatform) { $settings = @{ - type = "NotPTE" + type = 'NotPTE' unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "test.ps1"; destinationFolder = 'dstPath'; destinationName = "renamed.txt" }) + 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, $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.destinationFullPath -ccontains (Join-Path $baseFolder 'CaseFolder/conflict.txt')) | Should -BeTrue + ($filesToInclude.destinationFullPath -ccontains (Join-Path $baseFolder 'casefolder/conflict.txt')) | Should -BeFalse } - It 'Return the correct files with types' { + It 'GetFilesToUpdate retains a case-distinct removal when another destination is included on Linux' -Skip:(-not $script:isLinuxPlatform) { $settings = @{ - type = "NotPTE" + type = 'NotPTE' unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.ps1"; type = "script" }) - filesToExclude = @() + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @(@{ filter = 'test.ps1' }) } } - $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].type | Should -Be "script" - - # No files to remove - $filesToExclude | Should -BeNullOrEmpty + $filesToInclude[0].destinationFullPath | Should -BeExactly (Join-Path $baseFolder 'casefolder/conflict.txt') + $filesToExclude.Count | Should -Be 1 + $filesToExclude[0].destinationFullPath | Should -BeExactly (Join-Path $baseFolder 'CaseFolder/conflict.txt') + } + It 'GetFilesToUpdate suppresses a case-distinct removal when the destination is included on Windows' -Skip:(-not $script:isWindowsPlatform) { $settings = @{ - type = "NotPTE" + type = 'NotPTE' unusedALGoSystemFiles = @() customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt"; type = "text" }) - filesToExclude = @(@{ filter = "test.txt" }) + filesToInclude = @( + @{ filter = 'test.ps1'; destinationFolder = 'CaseFolder'; destinationName = 'conflict.txt' } + @{ filter = 'test.txt'; destinationFolder = 'casefolder'; destinationName = 'conflict.txt' } + ) + filesToExclude = @(@{ filter = 'test.ps1' }) } } - $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].type | Should -Be "text" + $filesToInclude[0].destinationFullPath | Should -BeExactly (Join-Path $baseFolder 'casefolder/conflict.txt') + $filesToExclude | Should -BeNullOrEmpty + } - # 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') + It 'GetFilesToUpdate excludes any case source path on Windows' -Skip:(-not $script:isWindowsPlatform) { + $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 'Return the correct files when unusedALGoSystemFiles is specified' { + It 'GetFilesToUpdate excludes any case unused file on Windows' -Skip:(-not $script:isWindowsPlatform) { + $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 = "nonPTE" - unusedALGoSystemFiles = @("test.ps1") - customALGoFiles = @{ - filesToInclude = @(@{ filter = "*" }) + 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 + $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 "GetWorkflowContentWithChangesFromSettings" { + BeforeAll { + $scriptRoot = Join-Path $PSScriptRoot '..\Actions\CheckForUpdates' -Resolve + . (Join-Path $scriptRoot 'yamlclass.ps1') + . (Join-Path -Path $scriptRoot -ChildPath '..\AL-Go-Helper.ps1' -Resolve) + . (Join-Path $scriptRoot 'CheckForUpdates.HelperFunctions.ps1') + } + + It 'Reads target workflow settings without the update event trigger' { + $srcFile = Join-Path $TestDrive 'Sample.yaml' + Set-Content -LiteralPath $srcFile -Value 'name: Sample Workflow' -Encoding UTF8 + Mock ReadSettings { [pscustomobject]@{ 'runs-on' = 'windows-latest'; shell = 'powershell' } } + + GetWorkflowContentWithChangesFromSettings -srcFile $srcFile -repoSettings @{} -depth 1 | Out-Null + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq 'Sample Workflow' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } + + It 'Uses current custom template settings for workflow generation and restores the snapshot' { + $baseFolder = Join-Path $TestDrive 'workflowBase' + $templateFolder = Join-Path $TestDrive 'workflowTemplate' + New-Item -ItemType Directory -Path (Join-Path $baseFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $templateFolder '.github') -Force | Out-Null + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"runs-on":"windows-latest","shell":"powershell"}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $templateFolder $RepoSettingsFile) -Value '{"runs-on":"ubuntu-latest","shell":"pwsh"}' -Encoding UTF8 + $srcFile = Join-Path $TestDrive 'Sample.yaml' + Set-Content -LiteralPath $srcFile -Value @('name: Sample Workflow', 'jobs:', ' Sample:', ' runs-on: [ windows-latest ]', ' steps:', ' - run: echo sample', ' shell: powershell') -Encoding UTF8 + + $originalWorkspace = $env:GITHUB_WORKSPACE + try { + $env:GITHUB_WORKSPACE = $baseFolder + $content = GetWorkflowContentWithChangesFromSettings -srcFile $srcFile -repoSettings @{} -depth 1 -customTemplateFolder $templateFolder + + $content | Should -Match 'runs-on: \[ ubuntu-latest \]' + $content | Should -Match 'shell: pwsh' + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent + } + finally { + $env:GITHUB_WORKSPACE = $originalWorkspace + } + } +} + +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 -customTemplateFolder $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 'Reads refreshed settings without execution-specific contexts' { + $templateFolder = Join-Path $TestDrive 'templateWithContexts' + $baseFolder = Join-Path $TestDrive 'baseWithContexts' + New-Item -ItemType Directory -Path (Join-Path $templateFolder '.github') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder '.github') -Force | Out-Null + Mock ReadSettings { [pscustomobject]@{ templateSha = 'test' } } + + $originalGitHubRepository = $env:GITHUB_REPOSITORY + try { + $env:GITHUB_REPOSITORY = 'contoso/context-policy-test' + ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder | Out-Null + + Should -Invoke ReadSettings -Exactly 1 -ParameterFilter { + $buildMode -ceq '' -and $project -ceq '' -and $workflowName -ceq '' -and + $userName -ceq '' -and $branchName -ceq '' -and $trigger -ceq '' -and + ($null -eq $repoName -or $repoName -ceq $env:GITHUB_REPOSITORY) + } + } + finally { + $env:GITHUB_REPOSITORY = $originalGitHubRepository + } + } + + 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 - $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[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 - # 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') + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + Test-Path -LiteralPath $snapshotFile | Should -Be $false + + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "current.txt" + Test-Path -LiteralPath $snapshotFile | Should -Be $false } - It 'GetFilesToUpdate with perProject true and empty projects returns no per-project entries' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt"; type = "text"; perProject = $true }) - filesToExclude = @() - } - } + 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 - # Pass empty projects array - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder -projects @() + $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 - # Behavior: when projects is empty, no per-project entries should be created - $filesToInclude | Should -BeNullOrEmpty - $filesToExclude | Should -BeNullOrEmpty + $settings = ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder + + $settings.customALGoFiles.filesToInclude[0].filter | Should -Be "existing.txt" + (Get-FileHash -LiteralPath $snapshotFile).Hash | Should -Be $snapshotHash } - It 'GetFilesToUpdate ignores filesToExclude patterns that do not match any file' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt" }) - filesToExclude = @(@{ filter = "no-match-*.none" }) - } - } + 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 - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{ invalid json' -Encoding UTF8 - # 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[1].sourceFullPath | Should -Be $testTxtFile2 - $filesToInclude[1].destinationFullPath | Should -Be (Join-Path 'baseFolder' 'test2.txt') + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 - $filesToExclude | Should -BeNullOrEmpty - } + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw - It 'GetFilesToUpdate duplicates per-project includes for each project including the repository root' { - $perProjectFile = Join-Path $templateFolder "perProjectFile.algo" - Set-Content -Path $perProjectFile -Value "per project" + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent + } - try { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "perProjectFile.algo"; perProject = $true; destinationFolder = 'custom' }) - filesToExclude = @() - } - } + It 'Throws and leaves the external target untouched when the base folder ".github" is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateForBaseGithubLinkEscape" + $baseFolder = Join-Path $TestDrive "baseWithGithubLinkEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForBaseLinkEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $baseFolder -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null - $projects = @('.', 'ProjectOne') - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder -projects $projects + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 - $filesToInclude | Should -Not -BeNullOrEmpty - $filesToInclude.Count | Should -Be 2 + $externalSnapshotFile = Join-Path $externalGithubFolder $CustomTemplateRepoSettingsFileName + $externalSnapshotContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalSnapshotFile -Value $externalSnapshotContent -Encoding UTF8 - $rootDestination = Join-Path 'baseFolder' 'custom/perProjectFile.algo' - $projectDestination = Join-Path 'baseFolder' 'ProjectOne/custom/perProjectFile.algo' + New-Item -ItemType SymbolicLink -Path (Join-Path $baseFolder ".github") -Target $externalGithubFolder -Force | Out-Null - $filesToInclude.destinationFullPath | Should -Contain $rootDestination - $filesToInclude.destinationFullPath | Should -Contain $projectDestination + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw - $filesToExclude | Should -BeNullOrEmpty - } - finally { - if (Test-Path $perProjectFile) { - Remove-Item -Path $perProjectFile -Force - } - } + Get-ContentLF -Path $externalSnapshotFile | Should -Be $externalSnapshotContent } - It 'GetFilesToUpdate adds custom template settings only when original template folder is provided' { - $customTemplateFolder = Join-Path $PSScriptRoot "customTemplateFolder" - $originalTemplateFolder = Join-Path $PSScriptRoot "originalTemplateFolder" + It 'Throws and leaves the external target untouched when the snapshot file itself is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateForSnapshotFileLinkEscape" + $baseFolder = Join-Path $TestDrive "baseWithSnapshotFileLinkEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null - New-Item -ItemType Directory -Path $customTemplateFolder -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $customTemplateFolder '.github') -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $customTemplateFolder '.AL-Go') -Force | Out-Null - Set-Content -Path (Join-Path $customTemplateFolder (Join-Path '.github' $RepoSettingsFileName)) -Value '{}' -Encoding UTF8 - Set-Content -Path (Join-Path $customTemplateFolder (Join-Path '.AL-Go' $ALGoSettingsFileName)) -Value '{}' -Encoding UTF8 + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 - New-Item -ItemType Directory -Path $originalTemplateFolder -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $originalTemplateFolder '.github') -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $originalTemplateFolder '.AL-Go') -Force | Out-Null - Set-Content -Path (Join-Path $originalTemplateFolder (Join-Path '.github' $RepoSettingsFileName)) -Value '{"original":true}' -Encoding UTF8 - Set-Content -Path (Join-Path $originalTemplateFolder (Join-Path '.AL-Go' $ALGoSettingsFileName)) -Value '{"original":true}' -Encoding UTF8 + $externalTargetFile = Join-Path $TestDrive "externalSnapshotTargetFile.json" + $externalTargetContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalTargetFile -Value $externalTargetContent -Encoding UTF8 - try { - $settings = @{ - type = "PTE" - powerPlatformSolutionFolder = '' - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @() - filesToExclude = @() - } - } + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + New-Item -ItemType SymbolicLink -Path $snapshotFile -Target $externalTargetFile -Force | Out-Null - $baseFolder = 'baseFolder' - $projects = @('ProjectA') + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw - $filesWithoutOriginal, $excludesWithoutOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -projects $projects + Get-ContentLF -Path $externalTargetFile | Should -Be $externalTargetContent + } - $filesWithoutOriginal | Should -Not -BeNullOrEmpty + It 'Throws and leaves the external target untouched when the template folder ".github" is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateWithGithubLinkEscape" + $baseFolder = Join-Path $TestDrive "baseForTemplateGithubLinkEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForTemplateLinkEscape" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null - $repoSettingsDestination = Join-Path $baseFolder (Join-Path '.github' $RepoSettingsFileName) - $projectSettingsRelative = Join-Path 'ProjectA' '.AL-Go' - $projectSettingsRelative = Join-Path $projectSettingsRelative $ALGoSettingsFileName - $projectSettingsDestination = Join-Path $baseFolder $projectSettingsRelative + $externalSettingsFile = Join-Path $externalGithubFolder $RepoSettingsFileName + $externalSettingsContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"external.txt"}]}}' + Set-Content -LiteralPath $externalSettingsFile -Value $externalSettingsContent -Encoding UTF8 - $filesWithoutOriginal.destinationFullPath | Should -Contain $repoSettingsDestination - $filesWithoutOriginal.destinationFullPath | Should -Contain $projectSettingsDestination - $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) - $filesWithoutOriginal.destinationFullPath | Should -Not -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) + New-Item -ItemType SymbolicLink -Path (Join-Path $templateFolder ".github") -Target $externalGithubFolder -Force | Out-Null - $filesWithOriginal, $excludesWithOriginal = GetFilesToUpdate -settings $settings -baseFolder $baseFolder -templateFolder $customTemplateFolder -originalTemplateFolder $originalTemplateFolder -projects $projects + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw - $filesWithOriginal | Should -Not -BeNullOrEmpty + Get-ContentLF -Path $externalSettingsFile | Should -Be $externalSettingsContent + } - $filesWithOriginal.destinationFullPath | Should -Contain $repoSettingsDestination - $filesWithOriginal.destinationFullPath | Should -Contain $projectSettingsDestination - $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateRepoSettingsFileName)) - $filesWithOriginal.destinationFullPath | Should -Contain (Join-Path $baseFolder (Join-Path '.github' $CustomTemplateProjectSettingsFileName)) + It 'Throws and leaves the external target and the existing snapshot untouched when the template settings file itself is a symlink redirecting elsewhere' -Skip:(-not $script:hasSymlinkCapability) { + $templateFolder = Join-Path $TestDrive "templateWithSettingsFileLinkEscape" + $baseFolder = Join-Path $TestDrive "baseForTemplateSettingsFileLinkEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null - $excludesWithoutOriginal | Should -BeNullOrEmpty - $excludesWithOriginal | Should -BeNullOrEmpty - } - finally { - if (Test-Path $customTemplateFolder) { - Remove-Item -Path $customTemplateFolder -Recurse -Force - } - if (Test-Path $originalTemplateFolder) { - Remove-Item -Path $originalTemplateFolder -Recurse -Force - } - } - } + $externalTargetFile = Join-Path $TestDrive "externalTemplateSettingsTargetFile.json" + $externalTargetContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalTargetFile -Value $externalTargetContent -Encoding UTF8 - It 'GetFilesToUpdate excludes files that match both include and exclude patterns' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt" }) - filesToExclude = @(@{ filter = "test.txt" }) - } - } + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + New-Item -ItemType SymbolicLink -Path $templateSettingsFile -Target $externalTargetFile -Force | Out-Null - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $snapshotFile = Join-Path $baseFolder $CustomTemplateRepoSettingsFile + $snapshotContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"stale.txt"}]}}' + Set-Content -LiteralPath $snapshotFile -Value $snapshotContent -Encoding UTF8 - # test.txt should not be in filesToInclude - $includedTestTxt = $filesToInclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } - $includedTestTxt | Should -BeNullOrEmpty + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw - # test.txt should be in filesToExclude - $excludedTestTxt = $filesToExclude | Where-Object { $_.sourceFullPath -eq (Join-Path $templateFolder "test.txt") } - $excludedTestTxt | Should -Not -BeNullOrEmpty + Get-ContentLF -Path $snapshotFile | Should -Be $snapshotContent + Get-ContentLF -Path $externalTargetFile | Should -Be $externalTargetContent } - It 'GetFilesToUpdate ignores exclude patterns that do not match any included file' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @(@{ filter = "*.txt" }) - filesToExclude = @(@{ filter = "nonexistent.xyz" }) - } - } + It 'Throws and leaves the external target untouched when the base folder ".github" is a junction redirecting elsewhere' -Skip:(-not $script:isWindowsPlatform) { + $templateFolder = Join-Path $TestDrive "templateForBaseGithubJunctionEscape" + $baseFolder = Join-Path $TestDrive "baseWithGithubJunctionEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForBaseJunctionEscape" + New-Item -ItemType Directory -Path (Join-Path $templateFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $baseFolder -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $templateSettingsFile = Join-Path $templateFolder $RepoSettingsFile + Set-Content -LiteralPath $templateSettingsFile -Value '{"customALGoFiles":{"filesToInclude":[{"filter":"current.txt"}]}}' -Encoding UTF8 - # All txt files should be included - $filesToInclude | Should -Not -BeNullOrEmpty - $txtFiles = $filesToInclude | Where-Object { $_.sourceFullPath -like "*.txt" } - $txtFiles.Count | Should -BeGreaterThan 0 + $externalSnapshotFile = Join-Path $externalGithubFolder $CustomTemplateRepoSettingsFileName + $externalSnapshotContent = '{"external":"untouched"}' + Set-Content -LiteralPath $externalSnapshotFile -Value $externalSnapshotContent -Encoding UTF8 - # Exclude list should not contain the non-matching pattern - $excludedNonExistent = $filesToExclude | Where-Object { $_.sourceFullPath -like "*.xyz" } - $excludedNonExistent | Should -BeNullOrEmpty + New-Item -ItemType Junction -Path (Join-Path $baseFolder ".github") -Target $externalGithubFolder -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalSnapshotFile | Should -Be $externalSnapshotContent } - It 'GetFilesToUpdate handles overlapping include patterns with different destinations' { - $settings = @{ - type = "NotPTE" - unusedALGoSystemFiles = @() - customALGoFiles = @{ - filesToInclude = @( - @{ filter = "test.txt"; destinationFolder = "folder1" } - @{ filter = "test.txt"; destinationFolder = "folder2" } - ) - filesToExclude = @() - } - } + It 'Throws and leaves the external target untouched when the template folder ".github" is a junction redirecting elsewhere' -Skip:(-not $script:isWindowsPlatform) { + $templateFolder = Join-Path $TestDrive "templateWithGithubJunctionEscape" + $baseFolder = Join-Path $TestDrive "baseForTemplateGithubJunctionEscape" + $externalGithubFolder = Join-Path $TestDrive "externalGithubForTemplateJunctionEscape" + New-Item -ItemType Directory -Path $templateFolder -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $baseFolder ".github") -Force | Out-Null + New-Item -ItemType Directory -Path $externalGithubFolder -Force | Out-Null - $filesToInclude, $filesToExclude = GetFilesToUpdate -settings $settings -baseFolder 'baseFolder' -templateFolder $templateFolder + $externalSettingsFile = Join-Path $externalGithubFolder $RepoSettingsFileName + $externalSettingsContent = '{"customALGoFiles":{"filesToInclude":[{"filter":"external.txt"}]}}' + Set-Content -LiteralPath $externalSettingsFile -Value $externalSettingsContent -Encoding UTF8 - # 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') + New-Item -ItemType Junction -Path (Join-Path $templateFolder ".github") -Target $externalGithubFolder -Force | Out-Null + + { ReadSettingsWithCurrentCustomTemplateRepoSettings -baseFolder $baseFolder -customTemplateFolder $templateFolder } | Should -Throw + + Get-ContentLF -Path $externalSettingsFile | Should -Be $externalSettingsContent } } @@ -2476,6 +4485,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' { @@ -2489,18 +4508,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" @@ -2512,30 +4593,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' { @@ -2549,7 +4626,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 @@ -2558,7 +4635,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' { @@ -2572,7 +4649,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 @@ -2582,9 +4659,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' { @@ -2600,7 +4677,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 @@ -2611,11 +4688,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 @@ -2625,17 +4702,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" @@ -2647,7 +4781,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 @@ -2671,7 +4805,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" } @@ -2689,7 +4823,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" }) @@ -2713,7 +4847,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*" } @@ -2736,7 +4870,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/Tests/GitHub-Helper.Test.ps1 b/Tests/GitHub-Helper.Test.ps1 index dca210a08f..0e0ff63a18 100644 --- a/Tests/GitHub-Helper.Test.ps1 +++ b/Tests/GitHub-Helper.Test.ps1 @@ -101,6 +101,27 @@ Describe "GitHub-Helper Tests" { . (Join-Path $PSScriptRoot '../Actions/AL-Go-Helper.ps1') } + It 'Get-ContentLF reads a literal path containing wildcard characters' { + $literalFile = Join-Path $TestDrive 'settings[1].json' + $wildcardMatch = Join-Path $TestDrive 'settings1.json' + Set-Content -LiteralPath $literalFile -Value "correct content" + Set-Content -LiteralPath $wildcardMatch -Value "wrong content" + + Get-ContentLF -path $literalFile | Should -Be "correct content" + } + + It 'Set-ContentLF writes to a literal path containing wildcard characters' { + $literalFile = Join-Path $TestDrive 'settings[1].json' + $wildcardMatch = Join-Path $TestDrive 'settings1.json' + Set-Content -LiteralPath $literalFile -Value "unchanged content" + Set-Content -LiteralPath $wildcardMatch -Value "unchanged content" + + Set-ContentLF -path $literalFile -content "changed content" + + Get-ContentLF -path $literalFile | Should -Be "changed content" + Get-ContentLF -path $wildcardMatch | Should -Be "unchanged content" + } + It 'SemVerStrToSemVerObj/SemVerObjToSemVerStr' { { SemVerStrToSemVerObj -semVerStr 'not semver' } | Should -Throw { SemVerStrToSemVerObj -semVerStr '' } | Should -Throw 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