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