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; -}