From 0bfb1b2e10b82212212e6f40d64c5894f9d6f183 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Mon, 12 Oct 2026 11:52:54 +0200 Subject: [PATCH] Default API tests to web service key authentication Authenticate API test requests with the current user's web service key and remove the container password and Key Vault credential plumbing (ApiTestCredential, password cleanup, PipelineFinalize hooks). Containers disable NavHttpClient anti-SSRF so API tests can call the local server. Clean-tenant execution logic is unchanged. Adds the shared SetLicenseSafeWorkDate and AddFieldToIgnoreIfExists helpers to Library - Graph Mgt. AB#646383 AB#653393 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3952f078-a881-4da8-ad96-13b727e48a91 --- .../Test Apps AT/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps AU/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps BE/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps CA/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps CH/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps CZ/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps DE/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps DK/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps ES/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps FI/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps FR/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps GB/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps IN/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps IS/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps IT/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps MX/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps NL/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps NO/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps NZ/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps RU/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps SE/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps US/.AL-Go/PipelineFinalize.ps1 | 1 - .../Test Apps W1/.AL-Go/PipelineFinalize.ps1 | 1 - build/scripts/ApiTestCredential.psm1 | 117 -------- build/scripts/NewBcContainer.ps1 | 11 +- build/scripts/PipelineFinalize.ps1 | 13 - build/scripts/Remove-ApiTestPassword.ps1 | 34 --- .../scripts/tests/ApiTestCredential.Test.ps1 | 249 ------------------ .../tests/Remove-ApiTestPassword.Test.ps1 | 223 ---------------- .../Misc/APITestAuthProviderTests.Codeunit.al | 125 ++++++++- .../APITestAuthentication.Enum.al | 12 +- .../APITestWebServiceKey.Codeunit.al | 24 ++ .../TestLibraries/LibraryGraphMgt.Codeunit.al | 22 ++ .../MicrosoftTestAuthProvider.Codeunit.al | 134 ++++++---- .../NoAPITestAuthProvider.Codeunit.al | 22 -- 35 files changed, 252 insertions(+), 757 deletions(-) delete mode 100644 build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 delete mode 100644 build/scripts/ApiTestCredential.psm1 delete mode 100644 build/scripts/PipelineFinalize.ps1 delete mode 100644 build/scripts/Remove-ApiTestPassword.ps1 delete mode 100644 build/scripts/tests/ApiTestCredential.Test.ps1 delete mode 100644 build/scripts/tests/Remove-ApiTestPassword.Test.ps1 create mode 100644 src/Layers/W1/Tests/TestLibraries/APITestWebServiceKey.Codeunit.al delete mode 100644 src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al diff --git a/build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps AT/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps AU/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps BE/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps CA/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps CH/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps CZ/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps DE/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps DK/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps ES/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps FI/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps FR/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps GB/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps IN/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps IS/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps IT/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps MX/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps NL/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps NO/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps NZ/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps RU/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps SE/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps US/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 b/build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 deleted file mode 100644 index a726947778c..00000000000 --- a/build/projects/Test Apps W1/.AL-Go/PipelineFinalize.ps1 +++ /dev/null @@ -1 +0,0 @@ -. (Join-Path $PSScriptRoot '../../../scripts/PipelineFinalize.ps1' -Resolve) diff --git a/build/scripts/ApiTestCredential.psm1 b/build/scripts/ApiTestCredential.psm1 deleted file mode 100644 index 5983d23d783..00000000000 --- a/build/scripts/ApiTestCredential.psm1 +++ /dev/null @@ -1,117 +0,0 @@ -function New-ApiTestPasswordFileStream { - param([Parameter(Mandatory = $true)][string]$FilePath) - - $ErrorActionPreference = 'Stop' - $security = [System.Security.AccessControl.FileSecurity]::new() - $security.SetAccessRuleProtection($true, $false) - $writerSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $entries = @( - @{ Sid = 'S-1-5-18'; Rights = [System.Security.AccessControl.FileSystemRights]::FullControl } - @{ Sid = 'S-1-5-20'; Rights = [System.Security.AccessControl.FileSystemRights]::Read } - @{ Sid = 'S-1-5-32-544'; Rights = [System.Security.AccessControl.FileSystemRights]::FullControl } - ) - if ($writerSid -notin @('S-1-5-18', 'S-1-5-32-544')) { - # The host identity writes the credential and deletes it during cleanup; it needs - # neither ReadData nor FullControl. Container service identities retain their access. - $entries += @{ - Sid = $writerSid - Rights = [System.Security.AccessControl.FileSystemRights]'Write, Delete' - } - } - foreach ($entry in $entries) { - $security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( - [System.Security.Principal.SecurityIdentifier]::new($entry.Sid), - $entry.Rights, - [System.Security.AccessControl.AccessControlType]::Allow)) - } - - # Supply the protected DACL to CreateFile itself, not Set-Acl after creation. - # CreateNew also refuses to follow or overwrite a pre-existing credential file. - if ($PSVersionTable.PSEdition -eq 'Core') { - return [System.IO.FileSystemAclExtensions]::Create( - [System.IO.FileInfo]::new($FilePath), [System.IO.FileMode]::CreateNew, - [System.Security.AccessControl.FileSystemRights]::Write, [System.IO.FileShare]::None, - 4096, [System.IO.FileOptions]::None, $security) - } - return [System.IO.FileStream]::new( - $FilePath, [System.IO.FileMode]::CreateNew, - [System.Security.AccessControl.FileSystemRights]::Write, [System.IO.FileShare]::None, - 4096, [System.IO.FileOptions]::None, $security) -} - -<# -.SYNOPSIS - Creates the API test credential directly in the container's shared my mount. -.DESCRIPTION - Records cleanup identity before atomically creating a new file with a protected - DACL. No plaintext staging copies are made. SYSTEM and Administrators retain full - access, NetworkService can read, and the host writer can write and delete. - An existing file or unresolved mount is an error, including outside CI. Successful - pipelines finalize cleanup; failed/cancelled pipelines rely on container teardown. -#> -function Write-ApiTestPassword { - param( - [Parameter(Mandatory = $true)] - [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9_.-]*$')] - [string]$ContainerName, - [Parameter(Mandatory = $true)] - [PSCredential]$Credential - ) - - $ErrorActionPreference = 'Stop' - $myFolders = @((Get-BcContainerSharedFolders -containerName $ContainerName).GetEnumerator() | - Where-Object { $_.Value.TrimEnd('\') -eq 'C:\Run\my' }) - if ($myFolders.Count -ne 1) { - throw "Cannot resolve the API test credential's container mount." - } - $filePath = Join-Path $myFolders[0].Key 'ApiTestPassword' - # PipelineFinalize runs in this process; GITHUB_ENV only affects later steps. - $env:BCAppsApiTestPasswordPath = $filePath - $env:BCAppsApiTestPasswordContainer = $ContainerName - if ($env:GITHUB_ENV) { - Add-Content -LiteralPath $env:GITHUB_ENV -Encoding UTF8 -Value @( - "BCAppsApiTestPasswordPath=$filePath" - "BCAppsApiTestPasswordContainer=$ContainerName" - ) -ErrorAction Stop - } - - $created = $false - $bytes = $null - $characters = $null - $passwordBuffer = [IntPtr]::Zero - try { - # Write directly to the shared mount. Copy-FileToBcContainer would introduce - # another host staging copy with inherited permissions. - $stream = New-ApiTestPasswordFileStream -FilePath $filePath - $created = $true - try { - $passwordBuffer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Credential.Password) - $characters = [char[]]::new($Credential.Password.Length) - [System.Runtime.InteropServices.Marshal]::Copy($passwordBuffer, $characters, 0, $characters.Length) - $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($characters) - $stream.Write($bytes, 0, $bytes.Length) - } - finally { - if ($null -ne $bytes) { [Array]::Clear($bytes, 0, $bytes.Length) } - if ($null -ne $characters) { [Array]::Clear($characters, 0, $characters.Length) } - if ($passwordBuffer -ne [IntPtr]::Zero) { - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordBuffer) - } - $stream.Dispose() - } - } - catch { - $originalError = $_ - if ($created) { - try { - & (Join-Path $PSScriptRoot 'Remove-ApiTestPassword.ps1') -ContainerName $ContainerName -FilePath $filePath - } - catch { - Write-Warning 'Could not clean up the API test credential after setup failed; container teardown is still required.' - } - } - throw $originalError - } -} - -Export-ModuleMember -Function Write-ApiTestPassword diff --git a/build/scripts/NewBcContainer.ps1 b/build/scripts/NewBcContainer.ps1 index 78871ab947b..674d6f1faa5 100644 --- a/build/scripts/NewBcContainer.ps1 +++ b/build/scripts/NewBcContainer.ps1 @@ -24,17 +24,10 @@ if ($platformVersion) { New-BcContainer @parameters -if ($parameters.auth -in @('UserPassword', 'NavUserPassword')) { - if (-not $parameters.credential) { - throw "The BCApps UserPassword test container requires a credential." - } - - Import-Module (Join-Path $PSScriptRoot 'ApiTestCredential.psm1') -Force - Write-ApiTestPassword -ContainerName $parameters.ContainerName -Credential $parameters.credential -} - Set-BcContainerServerConfiguration -containerName $parameters.ContainerName -keyName "EnforceUserPathForAlFileOperations" -keyValue "false" Set-BcContainerServerConfiguration -containerName $parameters.ContainerName -keyName "UsePermissionSetsFromExtensions" -keyValue "true" +# API tests call the container's own web services through AL HttpClient, which anti-SSRF blocks for local addresses (same as NAV RunALTests.ps1). +Set-BcContainerServerConfiguration -containerName $parameters.ContainerName -keyName "NavHttpClientAntiSSRFEnabled" -keyValue "false" Restart-BcContainer -containerName $parameters.ContainerName $installedApps = Get-BcContainerAppInfo -containerName $parameters.ContainerName -tenantSpecificProperties -sort DependenciesLast diff --git a/build/scripts/PipelineFinalize.ps1 b/build/scripts/PipelineFinalize.ps1 deleted file mode 100644 index 006bdc54b80..00000000000 --- a/build/scripts/PipelineFinalize.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -$ErrorActionPreference = 'Stop' - -# This hook is success-only. Failures/cancellation rely on normal container teardown, -# not guaranteed after runner loss. CI credentials are per-run; other callers may differ. -if (-not $env:BCAppsApiTestPasswordPath) { - return -} - -Write-Host 'API test credential cleanup (PipelineFinalize).' -& (Join-Path $PSScriptRoot 'Remove-ApiTestPassword.ps1') ` - -ContainerName $env:BCAppsApiTestPasswordContainer -FilePath $env:BCAppsApiTestPasswordPath -$env:BCAppsApiTestPasswordPath = $null -$env:BCAppsApiTestPasswordContainer = $null diff --git a/build/scripts/Remove-ApiTestPassword.ps1 b/build/scripts/Remove-ApiTestPassword.ps1 deleted file mode 100644 index 4c49720fe99..00000000000 --- a/build/scripts/Remove-ApiTestPassword.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -Param( - [Parameter(Mandatory = $true)] - [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9_.-]*$')] - [string]$ContainerName, - [Parameter(Mandatory = $true)] - [string]$FilePath -) - -$ErrorActionPreference = 'Stop' - -if ((Split-Path -Path $FilePath -Leaf) -ne 'ApiTestPassword') { - throw 'API test credential cleanup requires the exact ApiTestPassword file.' -} -if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { - return -} - -# End any remaining test workers before deleting their shared credential. -$containerIds = @(docker container ls --all --filter "name=^/$([regex]::Escape($ContainerName))$" --format '{{.ID}}') -if ($LASTEXITCODE -ne 0) { - throw 'Could not determine whether the API test container still exists.' -} -if ($containerIds.Count -gt 1) { - throw 'API test credential cleanup matched more than one container.' -} -if ($containerIds.Count -eq 1) { - docker stop --time 30 $containerIds[0] | Out-Null - if ($LASTEXITCODE -ne 0) { - throw 'Could not stop API test consumers; leaving credential removal to container teardown.' - } -} - -# The recorded mount path remains usable for stopped or already-removed containers. -Remove-Item -LiteralPath $FilePath -Force -ErrorAction Stop diff --git a/build/scripts/tests/ApiTestCredential.Test.ps1 b/build/scripts/tests/ApiTestCredential.Test.ps1 deleted file mode 100644 index f47051ac4e3..00000000000 --- a/build/scripts/tests/ApiTestCredential.Test.ps1 +++ /dev/null @@ -1,249 +0,0 @@ -Describe 'API test credential materialization' { - BeforeAll { - Import-Module (Join-Path $PSScriptRoot '..\ApiTestCredential.psm1') -Force - $script:previousGitHubEnv = $env:GITHUB_ENV - $script:previousPasswordPath = $env:BCAppsApiTestPasswordPath - $script:previousPasswordContainer = $env:BCAppsApiTestPasswordContainer - $script:previousExitCode = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction SilentlyContinue - $script:createdMountStub = -not (Get-Command Get-BcContainerSharedFolders -ListImported -ErrorAction SilentlyContinue) - if ($script:createdMountStub) { - function global:Get-BcContainerSharedFolders { - param([string]$containerName) - $null = $containerName - throw 'Container mount lookup must be mocked.' - } - } - $script:createdDockerStub = -not (Get-Command docker -ErrorAction SilentlyContinue) - if ($script:createdDockerStub) { - function global:docker { - param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) - $null = $Arguments - throw 'Docker must be mocked.' - } - } - $script:fixturePassword = [System.Security.SecureString]::new() - foreach ($character in 'synthetic-fixture-only'.ToCharArray()) { - $script:fixturePassword.AppendChar($character) - } - $script:fixturePassword.MakeReadOnly() - $script:credential = [PSCredential]::new('unit-test', $script:fixturePassword) - $script:mount = Join-Path $PSScriptRoot 'unused-mount' - } - - AfterAll { - $script:fixturePassword.Dispose() - $env:GITHUB_ENV = $script:previousGitHubEnv - $env:BCAppsApiTestPasswordPath = $script:previousPasswordPath - $env:BCAppsApiTestPasswordContainer = $script:previousPasswordContainer - $global:LASTEXITCODE = $script:previousExitCode - if ($script:createdMountStub) { Remove-Item function:global:Get-BcContainerSharedFolders } - if ($script:createdDockerStub) { Remove-Item function:global:docker } - } - - BeforeEach { - $env:GITHUB_ENV = Join-Path $PSScriptRoot 'unused-github-env' - $env:BCAppsApiTestPasswordPath = $null - $env:BCAppsApiTestPasswordContainer = $null - $script:events = [System.Collections.Generic.List[string]]::new() - $script:stream = [PSCustomObject]@{ - Events = $script:events - Buffer = $null - Written = $null - FailWrite = $false - FailDispose = $false - } - $script:stream | Add-Member ScriptMethod Write { - param($buffer, $offset, $count) - $this.Events.Add('write') - $this.Buffer = $buffer - $this.Written = [System.Text.Encoding]::UTF8.GetString($buffer, $offset, $count) - if ($this.FailWrite) { throw 'Synthetic write failure' } - } - $script:stream | Add-Member ScriptMethod Dispose { - $this.Events.Add('dispose') - if ($this.FailDispose) { throw 'Synthetic flush failure' } - } - Mock -ModuleName ApiTestCredential Get-BcContainerSharedFolders { @{ $script:mount = 'c:\run\my\' } } - Mock -ModuleName ApiTestCredential Add-Content { $script:events.Add('register') } - Mock -ModuleName ApiTestCredential New-ApiTestPasswordFileStream { - $env:BCAppsApiTestPasswordPath | Should -Be (Join-Path $script:mount 'ApiTestPassword') - $env:BCAppsApiTestPasswordContainer | Should -Be 'unit-test' - $script:events.Add('create') - $script:stream - } - Mock -ModuleName ApiTestCredential Test-Path { $true } - Mock -ModuleName ApiTestCredential docker { - $global:LASTEXITCODE = 0 - if ($args[0] -eq 'container') { 'synthetic-container-id' } - else { $script:events.Add('stop') } - } - Mock -ModuleName ApiTestCredential Remove-Item { $script:events.Add('delete') } - } - - It 'registers cleanup and creates the secured backing file before the first secret write' { - Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential - - ($script:events -join ',') | Should -Be 'register,create,write,dispose' - $script:stream.Written | Should -Be 'synthetic-fixture-only' - @($script:stream.Buffer | Where-Object { $_ -ne 0 }).Count | Should -Be 0 - Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 1 -Exactly -ParameterFilter { - $FilePath -eq (Join-Path $script:mount 'ApiTestPassword') - } - Should -Invoke -ModuleName ApiTestCredential Add-Content -Times 1 -Exactly -ParameterFilter { - $LiteralPath -eq $env:GITHUB_ENV -and $ErrorAction -eq 'Stop' -and - $Value.Count -eq 2 -and - $Value -contains "BCAppsApiTestPasswordPath=$(Join-Path $script:mount 'ApiTestPassword')" -and - $Value -contains 'BCAppsApiTestPasswordContainer=unit-test' - } - Should -Invoke -ModuleName ApiTestCredential docker -Times 0 - } - - It 'uses the same secured mount locally without workflow registration' { - $env:GITHUB_ENV = '' - Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential - - ($script:events -join ',') | Should -Be 'create,write,dispose' - Should -Invoke -ModuleName ApiTestCredential Get-BcContainerSharedFolders -Times 1 -Exactly - Should -Invoke -ModuleName ApiTestCredential Add-Content -Times 0 - } - - It 'preserves Unicode passwords while clearing the written byte buffer' { - $characters = [char[]]@(0x0061, 0x00E9, 0xD83D, 0xDD10) - $password = [System.Security.SecureString]::new() - try { - foreach ($character in $characters) { $password.AppendChar($character) } - $password.MakeReadOnly() - $credential = [PSCredential]::new('unit-test', $password) - - Write-ApiTestPassword -ContainerName 'unit-test' -Credential $credential - - $script:stream.Written | Should -Be (-join $characters) - @($script:stream.Buffer | Where-Object { $_ -ne 0 }).Count | Should -Be 0 - } - finally { - $password.Dispose() - } - } - - It 'does not materialize an immutable plaintext password string in the writer' { - $source = Get-Content (Join-Path $PSScriptRoot '..\ApiTestCredential.psm1') -Raw - $source | Should -Not -Match 'GetNetworkCredential|PtrToString' - $source | Should -Match 'SecureStringToBSTR' - $source | Should -Match 'ZeroFreeBSTR' - } - - It 'fails closed on an unresolved mount in CI and locally' { - Mock -ModuleName ApiTestCredential Get-BcContainerSharedFolders { @{} } - foreach ($environmentPath in @('unused-github-env', '')) { - $env:GITHUB_ENV = $environmentPath - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Cannot resolve*' - } - Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 0 - Should -Invoke -ModuleName ApiTestCredential Add-Content -Times 0 - } - - It 'fails closed on ambiguous mounts' { - Mock -ModuleName ApiTestCredential Get-BcContainerSharedFolders { @{ 'C:\first' = 'C:\Run\my'; 'C:\second' = 'c:\run\my' } } - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Cannot resolve*' - Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 0 - } - - It 'does not materialize credentials if workflow cleanup registration fails' { - Mock -ModuleName ApiTestCredential Add-Content { throw 'Synthetic registration failure' } - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic registration failure*' - Should -Invoke -ModuleName ApiTestCredential New-ApiTestPasswordFileStream -Times 0 - } - - It 'does not write or delete an existing file when secured creation fails' { - Mock -ModuleName ApiTestCredential New-ApiTestPasswordFileStream { throw 'Synthetic ACL or create failure' } - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic ACL or create failure*' - ($script:events -join ',') | Should -Be 'register' - Should -Invoke -ModuleName ApiTestCredential Remove-Item -Times 0 - } - - It 'closes the failed writer and stops consumers before deleting a partial file' { - $script:stream.FailWrite = $true - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic write failure*' - - ($script:events -join ',') | Should -Be 'register,create,write,dispose,stop,delete' - @($script:stream.Buffer | Where-Object { $_ -ne 0 }).Count | Should -Be 0 - } - - It 'also cleans up after a flush failure without reporting success' { - $script:stream.FailDispose = $true - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic flush failure*' - ($script:events -join ',') | Should -Be 'register,create,write,dispose,stop,delete' - } - - It 'preserves the setup error and credential when consumers cannot be stopped' { - $script:stream.FailWrite = $true - Mock -ModuleName ApiTestCredential docker { $global:LASTEXITCODE = 1 } -ParameterFilter { $args[0] -eq 'stop' } - Mock -ModuleName ApiTestCredential Write-Warning {} - - { Write-ApiTestPassword -ContainerName 'unit-test' -Credential $script:credential } | Should -Throw '*Synthetic write failure*' - Should -Invoke -ModuleName ApiTestCredential Remove-Item -Times 0 - Should -Invoke -ModuleName ApiTestCredential Write-Warning -Times 1 -Exactly - } -} - -Describe 'API test credential atomic Windows ACL' { - BeforeAll { - Import-Module (Join-Path $PSScriptRoot '..\ApiTestCredential.psm1') -Force - $script:aclRoot = Join-Path $PSScriptRoot ("acl-fixture-" + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $script:aclRoot | Out-Null - } - - AfterAll { - Remove-Item -LiteralPath $script:aclRoot -Recurse -Force - } - - It 'has only required explicit SIDs on an empty file before writing synthetic bytes' { - $path = Join-Path $script:aclRoot 'ApiTestPassword' - $stream = $null - try { - $stream = & (Get-Module ApiTestCredential) { param($path) New-ApiTestPasswordFileStream -FilePath $path } $path - $stream.Length | Should -Be 0 - $acl = Get-Acl -LiteralPath $path - $acl.AreAccessRulesProtected | Should -BeTrue - $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) - @($rules | Where-Object IsInherited).Count | Should -Be 0 - $writerSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $expectedSids = @('S-1-5-18', 'S-1-5-20', 'S-1-5-32-544', $writerSid) | Select-Object -Unique - ($rules.IdentityReference.Value | Sort-Object) | Should -Be ($expectedSids | Sort-Object) - foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) { - ($rules | Where-Object { $_.IdentityReference.Value -eq $sid }).FileSystemRights | - Should -Be ([System.Security.AccessControl.FileSystemRights]::FullControl) - } - $networkServiceRights = [System.Security.AccessControl.FileSystemRights]'Read, Synchronize' - if ($writerSid -eq 'S-1-5-20') { - $networkServiceRights = $networkServiceRights -bor [System.Security.AccessControl.FileSystemRights]'Write, Delete' - } - ($rules | Where-Object { $_.IdentityReference.Value -eq 'S-1-5-20' }).FileSystemRights | - Should -Be $networkServiceRights - if ($writerSid -notin @('S-1-5-18', 'S-1-5-20', 'S-1-5-32-544')) { - ($rules | Where-Object { $_.IdentityReference.Value -eq $writerSid }).FileSystemRights | - Should -Be ([System.Security.AccessControl.FileSystemRights]'Write, Delete, Synchronize') - } - $stream.WriteByte(65) - $stream.Length | Should -Be 1 - } - finally { - if ($stream) { $stream.Dispose() } - Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue - } - } - - It 'refuses to overwrite an existing file or change its ACL' { - $path = Join-Path $script:aclRoot 'ApiTestPassword' - [System.IO.File]::WriteAllText($path, 'synthetic-existing-fixture') - $before = (Get-Acl -LiteralPath $path).Sddl - try { - { & (Get-Module ApiTestCredential) { param($path) New-ApiTestPasswordFileStream -FilePath $path } $path } | Should -Throw - [System.IO.File]::ReadAllText($path) | Should -Be 'synthetic-existing-fixture' - (Get-Acl -LiteralPath $path).Sddl | Should -Be $before - } - finally { - Remove-Item -LiteralPath $path -Force - } - } -} diff --git a/build/scripts/tests/Remove-ApiTestPassword.Test.ps1 b/build/scripts/tests/Remove-ApiTestPassword.Test.ps1 deleted file mode 100644 index 72eab0f130c..00000000000 --- a/build/scripts/tests/Remove-ApiTestPassword.Test.ps1 +++ /dev/null @@ -1,223 +0,0 @@ -Describe 'API test credential cleanup' { - BeforeAll { - $script:cleanupScript = Join-Path $PSScriptRoot '..\Remove-ApiTestPassword.ps1' - $script:passwordPath = Join-Path $PSScriptRoot 'unused\ApiTestPassword' - $script:previousExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ValueOnly -ErrorAction SilentlyContinue - function docker { - param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) - $null = $Arguments - throw 'Docker must be mocked; these tests never contact a container.' - } - } - - AfterAll { - $global:LASTEXITCODE = $script:previousExitCode - } - - BeforeEach { - $script:events = [System.Collections.Generic.List[string]]::new() - Mock Test-Path { $true } - Mock docker { - $global:LASTEXITCODE = 0 - if ($Arguments[0] -eq 'container') { - 'test-container-id' - } - else { - $script:events.Add('stop') - } - } - Mock Remove-Item { $script:events.Add('delete') } - } - - It 'stops all consumers before deleting the exact backing file' { - . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath - - ($script:events -join ',') | Should -Be 'stop,delete' - Should -Invoke docker -Times 1 -Exactly -ParameterFilter { - $Arguments[0] -eq 'container' -and $Arguments -contains '--all' -and - $Arguments -contains 'name=^/test-container$' - } - Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { - $LiteralPath -eq $script:passwordPath -and $Force -and $ErrorAction -eq 'Stop' - } - } - - It 'does nothing when the password file is missing' { - Mock Test-Path { $false } - - . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath - - Should -Invoke docker -Times 0 - Should -Invoke Remove-Item -Times 0 - } - - It 'removes a leftover backing file when the container is already absent' { - Mock docker { $global:LASTEXITCODE = 0 } - - . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath - - ($script:events -join ',') | Should -Be 'delete' - Should -Invoke docker -Times 0 -ParameterFilter { $Arguments[0] -eq 'stop' } - } - - It 'also stops an already-stopped container idempotently before deletion' { - . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath - - Should -Invoke docker -Times 1 -Exactly -ParameterFilter { - ($Arguments -join ' ') -eq 'stop --time 30 test-container-id' - } - Should -Invoke Remove-Item -Times 1 -Exactly - } - - It 'fails without deleting when Docker cannot enumerate containers' { - Mock docker { $global:LASTEXITCODE = 1 } - - { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | - Should -Throw '*Could not determine*' - Should -Invoke Remove-Item -Times 0 - } - - It 'fails without deleting while consumers cannot be stopped' { - Mock docker { $global:LASTEXITCODE = 1 } -ParameterFilter { $Arguments[0] -eq 'stop' } - - { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | - Should -Throw '*Could not stop API test consumers*' - Should -Invoke Remove-Item -Times 0 - } - - It 'fails without stopping or deleting if container identity is ambiguous' { - Mock docker { - $global:LASTEXITCODE = 0 - 'first-id', 'second-id' - } - - { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | - Should -Throw '*more than one container*' - Should -Invoke docker -Times 0 -ParameterFilter { $Arguments[0] -eq 'stop' } - Should -Invoke Remove-Item -Times 0 - } - - It 'surfaces deletion failure rather than reporting successful cleanup' { - Mock Remove-Item { throw 'Access denied' } - - { . $script:cleanupScript -ContainerName 'test-container' -FilePath $script:passwordPath } | - Should -Throw '*Access denied*' - } - - It 'rejects a path that does not name the credential file' { - { . $script:cleanupScript -ContainerName 'test-container' -FilePath (Join-Path $TestDrive 'other-file') } | - Should -Throw '*exact ApiTestPassword file*' - Should -Invoke docker -Times 0 - Should -Invoke Remove-Item -Times 0 - } -} - -Describe 'API test credential workflow lifetime' { - BeforeAll { - $script:workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\..\.github\workflows\_BuildALGoProject.yaml') -Raw - $script:setup = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\NewBcContainer.ps1') -Raw - } - - It 'delegates credential materialization without host or container staging copies' { - $script:setup | Should -Match "Import-Module .*'ApiTestCredential.psm1'" - $script:setup | Should -Match 'Write-ApiTestPassword -ContainerName \$parameters.ContainerName -Credential \$parameters.credential' - $script:setup | Should -Not -Match 'Copy-FileToBcContainer|WriteAllText|GetNetworkCredential|GetTempPath' - } - - It 'leaves generated workflow cleanup unmodified' { - $script:workflow | Should -Not -Match '- name: Remove API test credential|Remove-ApiTestPassword.ps1' - $script:workflow | Should -Match '(?s)- name: Cleanup\r?\n\s+if: always\(\).*?uses: microsoft/AL-Go/Actions/PipelineCleanup@' - } -} - -Describe 'API test credential pipeline finalizer' { - BeforeAll { - $script:finalizer = Join-Path $PSScriptRoot '..\PipelineFinalize.ps1' - $script:previousPasswordPath = $env:BCAppsApiTestPasswordPath - $script:previousPasswordContainer = $env:BCAppsApiTestPasswordContainer - $script:previousExitCode = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction SilentlyContinue - function docker { - param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) - $null = $Arguments - throw 'Docker must be mocked.' - } - } - - BeforeEach { - $env:BCAppsApiTestPasswordPath = Join-Path $TestDrive 'ApiTestPassword' - $env:BCAppsApiTestPasswordContainer = 'recorded-container' - Mock Test-Path { $true } - Mock docker { - $global:LASTEXITCODE = 0 - if ($Arguments[0] -eq 'container') { 'recorded-container-id' } - } - Mock Remove-Item { - Should -Invoke docker -Times 1 -ParameterFilter { $Arguments[0] -eq 'stop' } - } - Mock Write-Host {} - } - - AfterAll { - $env:BCAppsApiTestPasswordPath = $script:previousPasswordPath - $env:BCAppsApiTestPasswordContainer = $script:previousPasswordContainer - $global:LASTEXITCODE = $script:previousExitCode - } - - It 'accepts no arguments and stops the recorded consumers before deleting the backing file' { - $path = $env:BCAppsApiTestPasswordPath - . $script:finalizer - Should -Invoke docker -Times 1 -Exactly -ParameterFilter { - $Arguments -contains 'name=^/recorded-container$' - } - Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { $LiteralPath -eq $path } - $env:BCAppsApiTestPasswordPath | Should -BeNullOrEmpty - $env:BCAppsApiTestPasswordContainer | Should -BeNullOrEmpty - } - - It 'does nothing when no credential was provisioned' { - $env:BCAppsApiTestPasswordPath = $null - . $script:finalizer - Should -Invoke docker -Times 0 - Should -Invoke Remove-Item -Times 0 - } - - It 'is idempotent when standard teardown already removed the credential' { - Mock Test-Path { $false } - . $script:finalizer - . $script:finalizer - Should -Invoke docker -Times 0 - Should -Invoke Remove-Item -Times 0 - $env:BCAppsApiTestPasswordPath | Should -BeNullOrEmpty - } - - It 'preserves cleanup identity and propagates a failure without deleting while consumers remain' { - Mock docker { $global:LASTEXITCODE = 1 } -ParameterFilter { $Arguments[0] -eq 'stop' } - { . $script:finalizer } | Should -Throw '*Could not stop API test consumers*' - Should -Invoke Remove-Item -Times 0 - $env:BCAppsApiTestPasswordContainer | Should -Be 'recorded-container' - $env:BCAppsApiTestPasswordPath | Should -Be (Join-Path $TestDrive 'ApiTestPassword') - } - - It 'propagates deletion failure without forgetting the recorded path' { - Mock Remove-Item { throw 'Synthetic deletion failure' } - { . $script:finalizer } | Should -Throw '*Synthetic deletion failure*' - $env:BCAppsApiTestPasswordPath | Should -Be (Join-Path $TestDrive 'ApiTestPassword') - } - - It 'runs every registered project wrapper through the AL-Go scriptblock invocation contract' { - $projects = Join-Path $PSScriptRoot '..\..\projects' - $wrappers = @(Get-ChildItem -LiteralPath $projects -Directory | ForEach-Object { - $setup = Join-Path $_.FullName '.AL-Go\NewBcContainer.ps1' - if ([IO.File]::Exists($setup)) { - Get-Item -LiteralPath (Join-Path $_.FullName '.AL-Go\PipelineFinalize.ps1') - } - }) - $wrappers.Count | Should -BeGreaterThan 0 - foreach ($wrapper in $wrappers) { - $env:BCAppsApiTestPasswordPath = Join-Path $TestDrive 'ApiTestPassword' - $env:BCAppsApiTestPasswordContainer = 'recorded-container' - Invoke-Command -ScriptBlock (Get-Command $wrapper.FullName).ScriptBlock - } - Should -Invoke Remove-Item -Times $wrappers.Count -Exactly - } -} diff --git a/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al b/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al index 68ad16c6b2a..bfc74e48717 100644 --- a/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al +++ b/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al @@ -9,6 +9,8 @@ codeunit 139494 "API Test Auth Provider Tests" { EventSubscriberInstance = Manual; Subtype = Test; + TestHttpRequestPolicy = AllowAllOutboundRequests; + RequiredTestIsolation = Disabled; TestPermissions = Disabled; trigger OnRun() @@ -23,6 +25,7 @@ codeunit 139494 "API Test Auth Provider Tests" LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryUtility: Codeunit "Library - Utility"; WebServiceManagement: Codeunit "Web Service Management"; + IdentityManagement: Codeunit "Identity Management"; SecondLibraryGraphMgt: Codeunit "Library - Graph Mgt"; FirstHttpRequestMessage: HttpRequestMessage; SecondHttpRequestMessage: HttpRequestMessage; @@ -33,17 +36,81 @@ codeunit 139494 "API Test Auth Provider Tests" UnexpectedCallErr: Label 'Unexpected authentication call.'; [Test] - procedure DefaultAuthenticationDoesNotConfigureRequest() + [NonDebuggable] + [TransactionModel(TransactionModel::AutoCommit)] + procedure MissingKeyIsCreatedForRequest() + var + TargetURL: Text; + ResponseText: Text; begin - // [SCENARIO] The default API test authentication does not configure the request + // [SCENARIO] First-use authentication creates a key visible to the independent HTTP session Initialize(); - // [GIVEN] A Graph library instance without an explicitly selected provider + // [GIVEN] The current user's key is missing at a committed fixture boundary + IdentityManagement.ClearWebServicesKey(UserSecurityId()); + Commit(); + TargetURL := GetUrl(ClientType::ODataV4); - // [WHEN] A web request is initialized - LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpRequestMessage, TargetURLTok); + // [WHEN] A request uses the default provider without explicit selection + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); - // [THEN] Only the final request event is raised + // [THEN] The request authenticates and only NavUserPassword requires a generated key + VerifyKeyPresenceForServerMode(); + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + [NonDebuggable] + [TransactionModel(TransactionModel::AutoCommit)] + procedure ExistingKeySurvivesLibraryReset() + var + ExistingKey: Text[80]; + TargetURL: Text; + ResponseText: Text; + begin + // [SCENARIO] A committed non-expiring key is reused across Graph library instances + Initialize(); + + // [GIVEN] The current user has a committed key without an expiry + ExistingKey := IdentityManagement.CreateWebServicesKeyNoExpiry(UserSecurityId()); + Commit(); + TargetURL := GetUrl(ClientType::ODataV4); + + // [WHEN] Two independently initialized library instances authenticate + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + Clear(LibraryGraphMgt); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + + // [THEN] Neither the key nor its expiry is changed + VerifyExistingKey(ExistingKey); + Clear(ExistingKey); + VerifyNextCall(EventCallTok); + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + [NonDebuggable] + [TransactionModel(TransactionModel::AutoCommit)] + procedure ExpiredKeyIsRenewedBeforeHttpRequest() + var + ExpiredKey: Text[80]; + ResponseText: Text; + begin + // [SCENARIO] NavUserPassword authentication replaces an expired key before the API request + Initialize(); + + // [GIVEN] The current user has a committed expired key + ExpiredKey := IdentityManagement.CreateWebServicesKey(UserSecurityId(), CurrentDateTime() - 60000); + Commit(); + + // [WHEN] The default provider sends the request + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, GetUrl(ClientType::ODataV4), 200); + + // [THEN] NavUserPassword renews the key while ambient authentication leaves it untouched + VerifyExpiredKeyForServerMode(ExpiredKey); + Clear(ExpiredKey); VerifyNextCall(EventCallTok); VerifyNoRemainingCalls(); end; @@ -95,7 +162,7 @@ codeunit 139494 "API Test Auth Provider Tests" // [SCENARIO] Authentication provider selection is scoped to a Graph library instance Initialize(); - // [GIVEN] Two Graph library instances where only the first uses the mock provider + // [GIVEN] One Graph library instance using the mock provider and another using the default LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); // [WHEN] Each Graph library instance initializes a web request @@ -132,17 +199,17 @@ codeunit 139494 "API Test Auth Provider Tests" end; [Test] - procedure SelectingNoneStopsConfiguringRequests() + procedure SelectingDefaultRestoresDefaultAuthentication() begin - // [SCENARIO] Selecting None replaces the previously selected provider + // [SCENARIO] Selecting Default replaces the previously selected mock provider Initialize(); // [GIVEN] A request has used the mock provider LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpRequestMessage, TargetURLTok); - // [WHEN] Authentication is deselected before another request - LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::None); + // [WHEN] Default authentication is selected before another request + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Default); LibraryGraphMgt.InitializeWebRequestWithURL(SecondHttpRequestMessage, TargetURLTok); // [THEN] Only the first request invokes the mock provider @@ -275,6 +342,42 @@ codeunit 139494 "API Test Auth Provider Tests" exit(LibraryGraphMgt.CreateTargetURL('', Page::"Customer List", '')); end; + [NonDebuggable] + local procedure GetCurrentWebServiceKey(): Text[80] + var + WebServiceKey: Text[80]; + begin + ClearLastError(); + WebServiceKey := IdentityManagement.GetWebServicesKey(UserSecurityId()); + Assert.IsTrue(GetLastErrorText() = '', 'The current user key must be readable.'); + exit(WebServiceKey); + end; + + [NonDebuggable] + local procedure VerifyKeyPresenceForServerMode() + begin + Assert.AreEqual( + IdentityManagement.IsUserNamePasswordAuthentication(), GetCurrentWebServiceKey() <> '', + 'Only NavUserPassword authentication should provision a key for the request.'); + end; + + [NonDebuggable] + local procedure VerifyExistingKey(ExistingKey: Text[80]) + begin + Assert.IsTrue(ExistingKey = GetCurrentWebServiceKey(), 'Authentication must not rotate an existing key.'); + Assert.AreEqual(0DT, IdentityManagement.GetWebServiceExpiryDate(UserSecurityId()), 'A non-expiring key must stay non-expiring.'); + end; + + [NonDebuggable] + local procedure VerifyExpiredKeyForServerMode(ExpiredKey: Text[80]) + begin + if IdentityManagement.IsUserNamePasswordAuthentication() then begin + Assert.IsTrue(ExpiredKey <> GetCurrentWebServiceKey(), 'An expired key must be replaced.'); + Assert.IsTrue(IdentityManagement.GetWebServiceExpiryDate(UserSecurityId()) > CurrentDateTime(), 'The replacement key must not be expired.'); + end else + Assert.IsTrue(ExpiredKey = GetCurrentWebServiceKey(), 'Ambient authentication must not change the key.'); + end; + local procedure VerifyNextCall(ExpectedCall: Text) begin APITestAuthProviderTests.VerifyRecordedCall(ExpectedCall); diff --git a/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al b/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al index c7ad8041b73..dac29b63510 100644 --- a/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al +++ b/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al @@ -11,16 +11,10 @@ enum 131023 "API Test Authentication" implements "API Test Auth Provider" { Extensible = true; - DefaultImplementation = "API Test Auth Provider" = "No API Test Auth Provider"; + DefaultImplementation = "API Test Auth Provider" = "Microsoft Test Auth Provider"; - value(0; None) + value(0; Default) { - Caption = 'None'; - Implementation = "API Test Auth Provider" = "No API Test Auth Provider"; - } - value(1; "Microsoft Test Environment") - { - Caption = 'Microsoft Test Environment'; - Implementation = "API Test Auth Provider" = "Microsoft Test Auth Provider"; + Caption = 'Default'; } } diff --git a/src/Layers/W1/Tests/TestLibraries/APITestWebServiceKey.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/APITestWebServiceKey.Codeunit.al new file mode 100644 index 00000000000..dccb0b60605 --- /dev/null +++ b/src/Layers/W1/Tests/TestLibraries/APITestWebServiceKey.Codeunit.al @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.TestLibraries.ERP; + +using System.Security.AccessControl; + +/// +/// Creates a web service key for the current user with a 24-hour expiry. +/// Started in a background session when possible, so the key is committed without committing the caller's transaction. +/// +codeunit 131025 "API Test Web Service Key" +{ + Access = Internal; + + trigger OnRun() + var + IdentityManagement: Codeunit "Identity Management"; + begin + IdentityManagement.CreateWebServicesKey(UserSecurityId(), CurrentDateTime() + 24 * 60 * 60 * 1000); + end; +} diff --git a/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al index 5f820abf482..affec140784 100644 --- a/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al +++ b/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al @@ -18,8 +18,24 @@ codeunit 130618 "Library - Graph Mgt" RemoteServerErr: Label 'The remote server returned an error: (%1) %2.', Comment = '%1 - response code number, %2 - reason phrase'; JsonContentTypeTok: Label 'application/json;odata.metadata=minimal', Locked = true; + /// Adds a field to the ignored-field buffer when the field exists in the source table. + /// Temporary ignored-field buffer. + /// Source table number. + /// Source field name. + procedure AddFieldToIgnoreIfExists(var TempIgnoredFields: Record 2000000041 temporary; SourceTableNo: Integer; SourceFieldName: Text) + var + RecordField: Record Field; + LibraryUtility: Codeunit "Library - Utility"; + begin + RecordField.SetRange(TableNo, SourceTableNo); + RecordField.SetRange(FieldName, SourceFieldName); + if RecordField.FindFirst() then + LibraryUtility.AddTempField(TempIgnoredFields, RecordField."No.", SourceTableNo); + end; + /// /// Sets the authentication provider used by this library instance. + /// Without an explicit selection, the enum's default implementation is used. /// Selecting the same provider preserves its cached state. /// /// The authentication provider to use for subsequent API test requests. @@ -33,6 +49,12 @@ codeunit 130618 "Library - Graph Mgt" AuthenticationProviderResolved := true; end; + /// Sets the work date to November 15 in the current year to stay within test-license date limits. + procedure SetLicenseSafeWorkDate() + begin + WorkDate := DMY2Date(15, 11, Date2DMY(Today, 3)); + end; + procedure EnsureWebServiceExist(ServiceNameTxt: Text[240]; PageNumber: Integer) var WebService: Record "Web Service"; diff --git a/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al index e0516d38988..55b3984c005 100644 --- a/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al +++ b/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al @@ -5,92 +5,132 @@ namespace Microsoft.TestLibraries.ERP; -using System; -using System.Azure.KeyVault; using System.Environment; using System.Security.AccessControl; -using System.Test.Library; /// -/// Provides authentication for API tests running in Microsoft test environments. +/// Provides API test authentication using the current user's web service key. /// codeunit 131022 "Microsoft Test Auth Provider" implements "API Test Auth Provider" { Access = Internal; var - ApiTestPasswordFileTok: Label 'C:\Run\my\ApiTestPassword', Locked = true; - NavServerUserPasswordKeyTok: Label 'NavServerUserPassword', Locked = true; - CachedAuthenticationPassword: SecretText; - ContainerPasswordReadErr: Label 'The API test password could not be read from %1.', Comment = '%1 - Password file path'; - KeyVaultPasswordReadErr: Label 'The API test password could not be retrieved from the %1 secret.', Comment = '%1 - Azure Key Vault secret name'; - PasswordRetrievalFailedErr: Label 'The API test password could not be retrieved.'; + KeyRetrievalFailedErr: Label 'The API test web service key could not be retrieved. Check the current user permissions.'; + KeyCreationFailedErr: Label 'The API test web service key could not be created. Check the current user permissions.'; + StartSessionFailedErr: Label 'The background session that creates the API test web service key could not be started.'; + KeyCreationTimeoutErr: Label 'The API test web service key was not created within %1 seconds.', Comment = '%1 - Timeout in seconds'; /// - /// Configures authentication for API test requests, preserving ambient authentication on Windows and SaaS. + /// Uses the current user's web service key only with NavUserPassword authentication on-premises. + /// Preserves ambient authentication for Windows, SaaS, and other authentication modes. + /// Valid keys are reused; missing or expired keys are created with a 24-hour expiry in a background session, + /// so the caller's transaction is not committed. Under a test runner with test isolation, which does not allow + /// background sessions, the key is created and committed in the caller's session instead. /// /// The authentication context to configure. procedure ConfigureAuthentication(var Authentication: Codeunit "API Test Auth Context") var EnvironmentInfo: Codeunit "Environment Information"; - SecurityGroup: Codeunit "Security Group"; + IdentityManagement: Codeunit "Identity Management"; begin if EnvironmentInfo.IsSaaSInfrastructure() then exit; - if SecurityGroup.IsWindowsAuthentication() then + if not IdentityManagement.IsUserNamePasswordAuthentication() then exit; - Authentication.SetBasicAuthentication(UserId(), GetAuthenticationPassword()); + Authentication.SetBasicAuthentication(UserId(), GetWebServiceKey()); end; - local procedure GetAuthenticationPassword(): SecretText + [NonDebuggable] + local procedure GetWebServiceKey(): SecretText var - Password: SecretText; + WebServiceKey: SecretText; + ExpiryDate: DateTime; begin - if not CachedAuthenticationPassword.IsEmpty() then - exit(CachedAuthenticationPassword); - - if ContainerPasswordFileExists() then begin - if not TryGetContainerPassword(Password) then - Error(ContainerPasswordReadErr, ApiTestPasswordFileTok); - end else - if not TryGetNavEnlistmentPassword(Password) then - Error(KeyVaultPasswordReadErr, NavServerUserPasswordKeyTok); + WebServiceKey := ReadWebServiceKey(ExpiryDate); + if IsMissingOrExpired(WebServiceKey, ExpiryDate) then begin + // The request is authenticated by a separate server session, which only sees committed data. + CreateWebServiceKey(); + SelectLatestVersion(); + WebServiceKey := ReadWebServiceKey(ExpiryDate); + if IsMissingOrExpired(WebServiceKey, ExpiryDate) then + Error(KeyCreationFailedErr); + end; + exit(WebServiceKey); + end; - CachedAuthenticationPassword := Password; - exit(CachedAuthenticationPassword); + local procedure IsMissingOrExpired(WebServiceKey: SecretText; ExpiryDate: DateTime): Boolean + begin + exit(WebServiceKey.IsEmpty() or ((ExpiryDate <> 0DT) and (ExpiryDate <= CurrentDateTime()))); end; - [Scope('OnPrem')] - local procedure ContainerPasswordFileExists(): Boolean + local procedure CreateWebServiceKey() var - File: DotNet File; + APITestWebServiceKey: Codeunit "API Test Web Service Key"; + SessionId: Integer; begin - exit(File.Exists(ApiTestPasswordFileTok)); + // A background session commits the new key without committing the caller's transaction. + if TryStartWebServiceKeySession(SessionId) then begin + WaitForWebServiceKeySession(SessionId); + exit; + end; + + // Test runners with test isolation do not allow background sessions. They roll back the test's + // changes when the test ends, including committed ones, so the key is committed in this session. + ClearLastError(); + APITestWebServiceKey.Run(); + Commit(); end; [TryFunction] - [NonDebuggable] - local procedure TryGetContainerPassword(var Password: SecretText) + local procedure TryStartWebServiceKeySession(var SessionId: Integer) + begin + if not StartSession(SessionId, Codeunit::"API Test Web Service Key", CompanyName()) then + Error(StartSessionFailedErr); + end; + + local procedure WaitForWebServiceKeySession(SessionId: Integer) var - MockAzureKeyvaultSecretProvider: DotNet MockAzureKeyVaultSecretProvider; + ActiveSession: Record "Active Session"; + TimeoutSec: Integer; + StartTime: DateTime; begin - // Read through a private provider without replacing the session's Azure Key Vault provider or cache. - MockAzureKeyvaultSecretProvider := MockAzureKeyvaultSecretProvider.MockAzureKeyVaultSecretProvider(); - MockAzureKeyvaultSecretProvider.AddSecretMappingFromFile(NavServerUserPasswordKeyTok, ApiTestPasswordFileTok); - Password := MockAzureKeyvaultSecretProvider.GetSecret(NavServerUserPasswordKeyTok); - if Password.IsEmpty() then - Error(PasswordRetrievalFailedErr); + TimeoutSec := 60; + StartTime := CurrentDateTime(); + while ActiveSession.Get(ServiceInstanceId(), SessionId) do begin + if CurrentDateTime() - StartTime > TimeoutSec * 1000 then begin + StopSession(SessionId); + Error(KeyCreationTimeoutErr, TimeoutSec); + end; + Sleep(250); + end; end; - [TryFunction] - local procedure TryGetNavEnlistmentPassword(var Password: SecretText) + [NonDebuggable] + local procedure ReadWebServiceKey(var ExpiryDate: DateTime): SecretText var - AzureKeyVault: Codeunit "Azure Key Vault"; + IdentityManagement: Codeunit "Identity Management"; + WebServiceKey: Text[80]; + SecretWebServiceKey: SecretText; begin - if not AzureKeyVault.GetAzureKeyVaultSecret(NavServerUserPasswordKeyTok, Password) then - Error(PasswordRetrievalFailedErr); - if Password.IsEmpty() then - Error(PasswordRetrievalFailedErr); + // Identity Management substitutes error text on failure. Check fresh error state, not key format. + ClearLastError(); + WebServiceKey := IdentityManagement.GetWebServicesKey(UserSecurityId()); + if GetLastErrorText() <> '' then begin + Clear(WebServiceKey); + Error(KeyRetrievalFailedErr); + end; + SecretWebServiceKey := WebServiceKey; + Clear(WebServiceKey); + + // The expiry getter also substitutes a value on failure. + ClearLastError(); + ExpiryDate := IdentityManagement.GetWebServiceExpiryDate(UserSecurityId()); + if GetLastErrorText() <> '' then begin + Clear(SecretWebServiceKey); + Error(KeyRetrievalFailedErr); + end; + exit(SecretWebServiceKey); end; } diff --git a/src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al deleted file mode 100644 index 6b744459e86..00000000000 --- a/src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. -// ------------------------------------------------------------------------------------------------ - -#pragma warning disable AA0247 - -/// -/// Leaves API test requests configured with the library's default authentication behavior (the default credentials of the Business Central service). -/// -codeunit 131024 "No API Test Auth Provider" implements "API Test Auth Provider" -{ - Access = Internal; - - /// - /// Leaves the authentication context unchanged. - /// - /// The authentication context to configure. - procedure ConfigureAuthentication(var Authentication: Codeunit "API Test Auth Context") - begin - end; -}