From 36564d2331401fc1d16f83b763b1ca5142d7044a Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Fri, 27 Mar 2026 17:44:02 +0000 Subject: [PATCH 01/10] Bugfix conversion of single activities in pipeline json files when handling dynamic variables --- private/Save-SynapseObjectAsFile.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/private/Save-SynapseObjectAsFile.ps1 b/private/Save-SynapseObjectAsFile.ps1 index 016af78..6fe47f0 100644 --- a/private/Save-SynapseObjectAsFile.ps1 +++ b/private/Save-SynapseObjectAsFile.ps1 @@ -7,6 +7,22 @@ function Save-SynapseObjectAsFile { $newFileName = Join-Path $obj.Synapse.Location "$($obj.Type)\~$($obj.Name).json" Write-Debug "Writing file: $newFileName" + # Fix PowerShell ConvertFrom-Json/ConvertTo-Json bug where single-element arrays become objects + # For pipeline objects, ensure activities is always an array + if ($obj.Type -eq 'pipeline' -and $obj.Body.properties.activities) { + if ($obj.Body.properties.activities -isnot [Array]) { + Write-Verbose "Converting single pipeline activity to array for: $($obj.Name)" + $obj.Body.properties.activities = @($obj.Body.properties.activities) + } + # Also fix nested activities inside ForEach/IfCondition/Switch/Until activities + foreach ($activity in $obj.Body.properties.activities) { + if ($activity.typeProperties.activities -and $activity.typeProperties.activities -isnot [Array]) { + Write-Verbose "Converting nested activities to array in $($activity.name)" + $activity.typeProperties.activities = @($activity.typeProperties.activities) + } + } + } + $output = ($obj.Body | ConvertTo-Json -Compress:$true -Depth 100) $Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False [IO.File]::WriteAllLines($newFileName, $output, $Utf8NoBomEncoding) From 2944e18ef1b91d156c6896fc8fd78f498e9ec4cb Mon Sep 17 00:00:00 2001 From: Alex Swann Date: Fri, 27 Mar 2026 18:15:07 +0000 Subject: [PATCH 02/10] Fix PSObject.Properties null-safe property checks --- private/Save-SynapseObjectAsFile.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/private/Save-SynapseObjectAsFile.ps1 b/private/Save-SynapseObjectAsFile.ps1 index 6fe47f0..ecca2ea 100644 --- a/private/Save-SynapseObjectAsFile.ps1 +++ b/private/Save-SynapseObjectAsFile.ps1 @@ -9,14 +9,14 @@ function Save-SynapseObjectAsFile { # Fix PowerShell ConvertFrom-Json/ConvertTo-Json bug where single-element arrays become objects # For pipeline objects, ensure activities is always an array - if ($obj.Type -eq 'pipeline' -and $obj.Body.properties.activities) { + if ($obj.Type -eq 'pipeline' -and $obj.Body.properties.PSObject.Properties['activities']) { if ($obj.Body.properties.activities -isnot [Array]) { Write-Verbose "Converting single pipeline activity to array for: $($obj.Name)" $obj.Body.properties.activities = @($obj.Body.properties.activities) } # Also fix nested activities inside ForEach/IfCondition/Switch/Until activities foreach ($activity in $obj.Body.properties.activities) { - if ($activity.typeProperties.activities -and $activity.typeProperties.activities -isnot [Array]) { + if ($activity.typeProperties.PSObject.Properties['activities'] -and $activity.typeProperties.activities -isnot [Array]) { Write-Verbose "Converting nested activities to array in $($activity.name)" $activity.typeProperties.activities = @($activity.typeProperties.activities) } From 621c68301993a33bbfdbf88584a5ce8b3e8258cd Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 17:40:44 +0100 Subject: [PATCH 03/10] Add unit tests to demonstrate ConvertTo-JSON activities bug --- private/Save-SynapseObjectAsFile.ps1 | 4 +- test/Save-SynapseObjectAsFile.Tests.ps1 | 100 ++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/Save-SynapseObjectAsFile.Tests.ps1 diff --git a/private/Save-SynapseObjectAsFile.ps1 b/private/Save-SynapseObjectAsFile.ps1 index ecca2ea..396c091 100644 --- a/private/Save-SynapseObjectAsFile.ps1 +++ b/private/Save-SynapseObjectAsFile.ps1 @@ -16,7 +16,9 @@ function Save-SynapseObjectAsFile { } # Also fix nested activities inside ForEach/IfCondition/Switch/Until activities foreach ($activity in $obj.Body.properties.activities) { - if ($activity.typeProperties.PSObject.Properties['activities'] -and $activity.typeProperties.activities -isnot [Array]) { + if ($null -ne $activity.PSObject.Properties['typeProperties'] -and + $null -ne $activity.typeProperties.PSObject.Properties['activities'] -and + $activity.typeProperties.activities -isnot [Array]) { Write-Verbose "Converting nested activities to array in $($activity.name)" $activity.typeProperties.activities = @($activity.typeProperties.activities) } diff --git a/test/Save-SynapseObjectAsFile.Tests.ps1 b/test/Save-SynapseObjectAsFile.Tests.ps1 new file mode 100644 index 0000000..a4125cc --- /dev/null +++ b/test/Save-SynapseObjectAsFile.Tests.ps1 @@ -0,0 +1,100 @@ +BeforeDiscovery { + $ModuleRootPath = $PSScriptRoot | Split-Path -Parent + $moduleManifestName = 'azure.synapse.tools.psd1' + $moduleManifestPath = Join-Path -Path $ModuleRootPath -ChildPath $moduleManifestName + + Import-Module -Name $moduleManifestPath -Force -Verbose:$false +} + +InModuleScope azure.synapse.tools { + $testHelperPath = $PSScriptRoot | Join-Path -ChildPath 'TestHelper' + Import-Module -Name $testHelperPath -Force + + Describe 'Save-SynapseObjectAsFile' -Tag 'Unit' { + + BeforeAll { + $script:tmpDir = New-TemporaryDirectory + New-Item -ItemType Directory -Path (Join-Path $script:tmpDir.FullName 'pipeline') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:tmpDir.FullName 'dataset') -Force | Out-Null + } + + AfterAll { + Remove-Item $script:tmpDir.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Should exist' { + { Get-Command -Name Save-SynapseObjectAsFile -ErrorAction Stop } | Should -Not -Throw + } + + Context 'Single top-level activity coerced to object by ConvertFrom-Json' { + BeforeAll { + $synapse = [Synapse]::new() + $synapse.Location = $script:tmpDir.FullName + $obj = [SynapseObject]::new() + $obj.Name = 'pl_single_activity' + $obj.Type = 'pipeline' + $obj.Synapse = $synapse + $obj.Body = '{"name":"pl_single_activity","properties":{"activities":{"name":"act1","type":"Copy","typeProperties":{}}}}' | ConvertFrom-Json + $script:result = Save-SynapseObjectAsFile -obj $obj + } + It 'Should serialise activities as a JSON array' { + $raw = Get-Content $script:result -Raw + $raw | Should -Match '"activities":\[' + $raw | Should -Not -Match '"activities":\{' + } + } + + Context 'Multiple top-level activities already an array' { + BeforeAll { + $synapse = [Synapse]::new() + $synapse.Location = $script:tmpDir.FullName + $obj = [SynapseObject]::new() + $obj.Name = 'pl_multi_activity' + $obj.Type = 'pipeline' + $obj.Synapse = $synapse + $obj.Body = '{"name":"pl_multi_activity","properties":{"activities":[{"name":"act1","type":"Copy","typeProperties":{}},{"name":"act2","type":"Copy","typeProperties":{}}]}}' | ConvertFrom-Json + $script:result = Save-SynapseObjectAsFile -obj $obj + } + It 'Should preserve all activities and write them as a JSON array' { + $json = Get-Content $script:result -Raw | ConvertFrom-Json + @($json.properties.activities).Count | Should -Be 2 + } + } + + Context 'ForEach nested single activity coerced to object by ConvertFrom-Json' { + BeforeAll { + $synapse = [Synapse]::new() + $synapse.Location = $script:tmpDir.FullName + $obj = [SynapseObject]::new() + $obj.Name = 'pl_foreach_single_nested_activity' + $obj.Type = 'pipeline' + $obj.Synapse = $synapse + $obj.Body = '{"name":"pl_foreach_single_nested_activity","properties":{"activities":[{"name":"forEach1","type":"ForEach","typeProperties":{"activities":{"name":"act1","type":"Copy","typeProperties":{}}}}]}}' | ConvertFrom-Json + $script:result = Save-SynapseObjectAsFile -obj $obj + } + It 'Should serialise nested typeProperties.activities as a JSON array' { + $raw = Get-Content $script:result -Raw + $raw | Should -Match '"typeProperties":\{"activities":\[' + $raw | Should -Not -Match '"typeProperties":\{"activities":\{' + } + } + + Context 'Non-pipeline object' { + BeforeAll { + $synapse = [Synapse]::new() + $synapse.Location = $script:tmpDir.FullName + $obj = [SynapseObject]::new() + $obj.Name = 'ds_test' + $obj.Type = 'dataset' + $obj.Synapse = $synapse + $obj.Body = '{"name":"ds_test","properties":{"type":"AzureBlob"}}' | ConvertFrom-Json + $script:result = Save-SynapseObjectAsFile -obj $obj + } + It 'Should write the object to file without modification' { + $json = Get-Content $script:result -Raw | ConvertFrom-Json + $json.name | Should -Be 'ds_test' + $json.properties.type | Should -Be 'AzureBlob' + } + } + } +} From b600647d4274fa54d6a304752a8f96a5404b0319 Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 18:29:43 +0100 Subject: [PATCH 04/10] Fix auth token: check Az.Accounts module version (>=3.0.0) for SecureString token handling instead of unreliable command version check --- private/Get-RequestHeader.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/private/Get-RequestHeader.ps1 b/private/Get-RequestHeader.ps1 index 10e660f..75299fd 100644 --- a/private/Get-RequestHeader.ps1 +++ b/private/Get-RequestHeader.ps1 @@ -1,7 +1,7 @@ function Get-RequestHeader { - $Version = (Get-Command -Name Get-AzAccessToken).Version + $AzAccountsVersion = (Get-Module Az.Accounts).Version $SynapseToken = Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net' - if ($Version -ge '5.0.0') { + if ($AzAccountsVersion -ge '3.0.0') { $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SynapseToken.Token) $token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) From 5a3ae6a8dc545cbb225f188c4be1214bbb5cad18 Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 18:32:45 +0100 Subject: [PATCH 05/10] Add null-safety to Az.Accounts version check and unit tests for Get-RequestHeader token handling --- private/Get-RequestHeader.ps1 | 2 +- test/Get-RequestHeader.Tests.ps1 | 104 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 test/Get-RequestHeader.Tests.ps1 diff --git a/private/Get-RequestHeader.ps1 b/private/Get-RequestHeader.ps1 index 75299fd..bb7f552 100644 --- a/private/Get-RequestHeader.ps1 +++ b/private/Get-RequestHeader.ps1 @@ -1,5 +1,5 @@ function Get-RequestHeader { - $AzAccountsVersion = (Get-Module Az.Accounts).Version + $AzAccountsVersion = (Get-Module Az.Accounts).Version ?? [version]'0.0.0' $SynapseToken = Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net' if ($AzAccountsVersion -ge '3.0.0') { $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SynapseToken.Token) diff --git a/test/Get-RequestHeader.Tests.ps1 b/test/Get-RequestHeader.Tests.ps1 new file mode 100644 index 0000000..64d598c --- /dev/null +++ b/test/Get-RequestHeader.Tests.ps1 @@ -0,0 +1,104 @@ +BeforeDiscovery { + $ModuleRootPath = $PSScriptRoot | Split-Path -Parent + $moduleManifestName = 'azure.synapse.tools.psd1' + $moduleManifestPath = Join-Path -Path $ModuleRootPath -ChildPath $moduleManifestName + + Import-Module -Name $moduleManifestPath -Force -Verbose:$false +} + +InModuleScope azure.synapse.tools { + $testHelperPath = $PSScriptRoot | Join-Path -ChildPath 'TestHelper' + Import-Module -Name $testHelperPath -Force + + Describe 'Get-RequestHeader' -Tag 'Unit' { + + It 'Should exist' { + { Get-Command -Name Get-RequestHeader -ErrorAction Stop } | Should -Not -Throw + } + + Context 'Az.Accounts >= 3.0.0 (SecureString token)' { + BeforeAll { + $plainToken = 'eyJhbGciOiJSUzI1NiJ9.payload.sig' + $secureToken = ConvertTo-SecureString $plainToken -AsPlainText -Force + + Mock Get-Module { + [PSCustomObject]@{ Version = [version]'3.0.0' } + } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools + + Mock Get-AzAccessToken { + [PSCustomObject]@{ Token = $secureToken } + } -ModuleName azure.synapse.tools + + $script:header = Get-RequestHeader + } + + It 'Should return a Bearer token header' { + $script:header['Authorization'] | Should -Match '^Bearer ' + } + + It 'Should unwrap the SecureString and produce a valid plain-text token' { + $script:header['Authorization'] | Should -Be "Bearer $plainToken" + } + + It 'Should set Content-Type to application/json' { + $script:header['Content-Type'] | Should -Be 'application/json' + } + } + + Context 'Az.Accounts < 3.0.0 (plain string token)' { + BeforeAll { + $plainToken = 'eyJhbGciOiJSUzI1NiJ9.payload.sig' + + Mock Get-Module { + [PSCustomObject]@{ Version = [version]'2.9.0' } + } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools + + Mock Get-AzAccessToken { + [PSCustomObject]@{ token = $plainToken } + } -ModuleName azure.synapse.tools + + $script:header = Get-RequestHeader + } + + It 'Should return a Bearer token header' { + $script:header['Authorization'] | Should -Match '^Bearer ' + } + + It 'Should use the plain-text token directly' { + $script:header['Authorization'] | Should -Be "Bearer $plainToken" + } + + It 'Should set Content-Type to application/json' { + $script:header['Content-Type'] | Should -Be 'application/json' + } + } + + Context 'Az.Accounts module not loaded (null version fallback)' { + BeforeAll { + $plainToken = 'eyJhbGciOiJSUzI1NiJ9.payload.sig' + $secureToken = ConvertTo-SecureString $plainToken -AsPlainText -Force + + Mock Get-Module { + $null + } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools + + # Get-AzAccessToken requires Az.Accounts to be loaded in real use, + # but in this edge case the Az.Synapse context may still have it available. + # Simulate a modern token shape to confirm null-version falls back to 0.0.0 -> else branch. + Mock Get-AzAccessToken { + [PSCustomObject]@{ token = $plainToken } + } -ModuleName azure.synapse.tools + + $script:header = Get-RequestHeader + } + + It 'Should not throw' { + { Get-RequestHeader } | Should -Not -Throw + } + + It 'Should still return a valid Authorization header' { + $script:header['Authorization'] | Should -Be "Bearer $plainToken" + } + } + } +} From 49023ba20ea708711c5a98ac33ca1e5dfc32c0e6 Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 18:51:40 +0100 Subject: [PATCH 06/10] Use -AsPlainText instead of BSTR marshal for Az.Accounts >= 3.0.0 token retrieval --- private/Get-RequestHeader.ps1 | 7 ++----- test/Get-RequestHeader.Tests.ps1 | 12 ++++-------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/private/Get-RequestHeader.ps1 b/private/Get-RequestHeader.ps1 index bb7f552..508f854 100644 --- a/private/Get-RequestHeader.ps1 +++ b/private/Get-RequestHeader.ps1 @@ -1,13 +1,10 @@ function Get-RequestHeader { $AzAccountsVersion = (Get-Module Az.Accounts).Version ?? [version]'0.0.0' - $SynapseToken = Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net' if ($AzAccountsVersion -ge '3.0.0') { - $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SynapseToken.Token) - $token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) + $token = (Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net' -AsPlainText).Token } else { - $token = $SynapseToken.token + $token = (Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net').token } #$token = Get-AzAccessToken -ResourceUrl 'https://management.azure.com' #audience $Header = @{ diff --git a/test/Get-RequestHeader.Tests.ps1 b/test/Get-RequestHeader.Tests.ps1 index 64d598c..ceda72c 100644 --- a/test/Get-RequestHeader.Tests.ps1 +++ b/test/Get-RequestHeader.Tests.ps1 @@ -16,17 +16,16 @@ InModuleScope azure.synapse.tools { { Get-Command -Name Get-RequestHeader -ErrorAction Stop } | Should -Not -Throw } - Context 'Az.Accounts >= 3.0.0 (SecureString token)' { + Context 'Az.Accounts >= 3.0.0 (AsPlainText token)' { BeforeAll { $plainToken = 'eyJhbGciOiJSUzI1NiJ9.payload.sig' - $secureToken = ConvertTo-SecureString $plainToken -AsPlainText -Force Mock Get-Module { [PSCustomObject]@{ Version = [version]'3.0.0' } } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools Mock Get-AzAccessToken { - [PSCustomObject]@{ Token = $secureToken } + [PSCustomObject]@{ Token = $plainToken } } -ModuleName azure.synapse.tools $script:header = Get-RequestHeader @@ -36,7 +35,7 @@ InModuleScope azure.synapse.tools { $script:header['Authorization'] | Should -Match '^Bearer ' } - It 'Should unwrap the SecureString and produce a valid plain-text token' { + It 'Should produce a valid plain-text token' { $script:header['Authorization'] | Should -Be "Bearer $plainToken" } @@ -76,15 +75,12 @@ InModuleScope azure.synapse.tools { Context 'Az.Accounts module not loaded (null version fallback)' { BeforeAll { $plainToken = 'eyJhbGciOiJSUzI1NiJ9.payload.sig' - $secureToken = ConvertTo-SecureString $plainToken -AsPlainText -Force Mock Get-Module { $null } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools - # Get-AzAccessToken requires Az.Accounts to be loaded in real use, - # but in this edge case the Az.Synapse context may still have it available. - # Simulate a modern token shape to confirm null-version falls back to 0.0.0 -> else branch. + # Null version falls back to 0.0.0 -> else branch -> plain string .token property Mock Get-AzAccessToken { [PSCustomObject]@{ token = $plainToken } } -ModuleName azure.synapse.tools From fc008f7afff34e0c624c6e37de1a1843568ff3df Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 18:53:43 +0100 Subject: [PATCH 07/10] Fix SecureString unwrap on Linux: use CoTaskMemUnicode+PtrToStringUni instead of BSTR, detect by type not version --- private/Get-RequestHeader.ps1 | 10 ++++++---- test/Get-RequestHeader.Tests.ps1 | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/private/Get-RequestHeader.ps1 b/private/Get-RequestHeader.ps1 index 508f854..c5c550d 100644 --- a/private/Get-RequestHeader.ps1 +++ b/private/Get-RequestHeader.ps1 @@ -1,10 +1,12 @@ function Get-RequestHeader { - $AzAccountsVersion = (Get-Module Az.Accounts).Version ?? [version]'0.0.0' - if ($AzAccountsVersion -ge '3.0.0') { - $token = (Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net' -AsPlainText).Token + $SynapseToken = Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net' + if ($SynapseToken.Token -is [System.Security.SecureString]) { + $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($SynapseToken.Token) + $token = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) + [System.Runtime.InteropServices.Marshal]::ZeroFreeCoTaskMemUnicode($ptr) } else { - $token = (Get-AzAccessToken -ResourceUrl 'https://dev.azuresynapse.net').token + $token = $SynapseToken.Token ?? $SynapseToken.token } #$token = Get-AzAccessToken -ResourceUrl 'https://management.azure.com' #audience $Header = @{ diff --git a/test/Get-RequestHeader.Tests.ps1 b/test/Get-RequestHeader.Tests.ps1 index ceda72c..1e8baa9 100644 --- a/test/Get-RequestHeader.Tests.ps1 +++ b/test/Get-RequestHeader.Tests.ps1 @@ -16,16 +16,17 @@ InModuleScope azure.synapse.tools { { Get-Command -Name Get-RequestHeader -ErrorAction Stop } | Should -Not -Throw } - Context 'Az.Accounts >= 3.0.0 (AsPlainText token)' { + Context 'Az.Accounts >= 3.0.0 (SecureString token - cross-platform unicode unwrap)' { BeforeAll { $plainToken = 'eyJhbGciOiJSUzI1NiJ9.payload.sig' + $secureToken = ConvertTo-SecureString $plainToken -AsPlainText -Force Mock Get-Module { [PSCustomObject]@{ Version = [version]'3.0.0' } } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools Mock Get-AzAccessToken { - [PSCustomObject]@{ Token = $plainToken } + [PSCustomObject]@{ Token = $secureToken } } -ModuleName azure.synapse.tools $script:header = Get-RequestHeader @@ -35,7 +36,7 @@ InModuleScope azure.synapse.tools { $script:header['Authorization'] | Should -Match '^Bearer ' } - It 'Should produce a valid plain-text token' { + It 'Should correctly unwrap the SecureString to a plain-text token' { $script:header['Authorization'] | Should -Be "Bearer $plainToken" } @@ -80,7 +81,7 @@ InModuleScope azure.synapse.tools { $null } -ParameterFilter { $Name -eq 'Az.Accounts' } -ModuleName azure.synapse.tools - # Null version falls back to 0.0.0 -> else branch -> plain string .token property + # Plain string token (old Az.Accounts < 3.0.0) uses .token property Mock Get-AzAccessToken { [PSCustomObject]@{ token = $plainToken } } -ModuleName azure.synapse.tools From bcd3bb9337e4c00cdb23aff12e183439f7609d9b Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 19:05:23 +0100 Subject: [PATCH 08/10] Fallback to REST API when Get-AzSynapsePipeline fails on single-activity pipeline object deserialization --- public/Get-SynapseFromService.ps1 | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/public/Get-SynapseFromService.ps1 b/public/Get-SynapseFromService.ps1 index 8c61250..5f8c49a 100644 --- a/public/Get-SynapseFromService.ps1 +++ b/public/Get-SynapseFromService.ps1 @@ -23,6 +23,12 @@ function Get-SynapseFromService { ) Write-Debug "BEGIN: Get-SynapseromService(WorkspaceName=$WorkspaceName" + # Minimal wrapper class used as REST-API fallback for Get-AzSynapsePipeline. + # Named 'PipelineResource' so Get-SimplifiedType strips 'Resource' -> 'Pipeline'. + if (-not ('PipelineResource' -as [type])) { + Add-Type -TypeDefinition 'public class PipelineResource { public string Name { get; set; } }' + } + $synapse = New-Object -TypeName Synapse $synapse.Name = $WorkspaceName @@ -39,7 +45,18 @@ function Get-SynapseFromService { Write-Host ("IntegrationRuntimes: {0} object(s) loaded." -f $synapse.IntegrationRuntimes.Count) $synapse.LinkedServices = Get-AzSynapseLinkedService -WorkspaceName $WorkspaceName | ToArray Write-Host ("LinkedServices: {0} object(s) loaded." -f $synapse.LinkedServices.Count) - $synapse.Pipelines = Get-AzSynapsePipeline -WorkspaceName $WorkspaceName | ToArray + try { + $synapse.Pipelines = Get-AzSynapsePipeline -WorkspaceName $WorkspaceName | ToArray + } catch { + Write-Warning "Get-AzSynapsePipeline failed (likely a single-activity pipeline stored as object in Synapse): falling back to REST API. Error: $_" + $h = Get-RequestHeader + $response = Invoke-RestMethod -Method GET -Uri "https://$WorkspaceName.dev.azuresynapse.net/pipelines?api-version=2020-12-01" -Headers $h + $synapse.Pipelines = @($response.value | ForEach-Object { + $p = New-Object PipelineResource + $p.Name = $_.name + $p + }) + } Write-Host ("Pipelines: {0} object(s) loaded." -f $synapse.Pipelines.Count) $synapse.DataFlows = Get-AzSynapseDataFlow -WorkspaceName $WorkspaceName | ToArray Write-Host ("DataFlows: {0} object(s) loaded." -f $synapse.DataFlows.Count) From 626a7df4837578e6920059ad9635510d713a79a5 Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 19:06:35 +0100 Subject: [PATCH 09/10] Add unit tests for Get-SynapseFromService pipeline REST fallback --- test/Get-SynapseFromService.Tests.ps1 | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 test/Get-SynapseFromService.Tests.ps1 diff --git a/test/Get-SynapseFromService.Tests.ps1 b/test/Get-SynapseFromService.Tests.ps1 new file mode 100644 index 0000000..d529031 --- /dev/null +++ b/test/Get-SynapseFromService.Tests.ps1 @@ -0,0 +1,106 @@ +BeforeDiscovery { + $ModuleRootPath = $PSScriptRoot | Split-Path -Parent + $moduleManifestName = 'azure.synapse.tools.psd1' + $moduleManifestPath = Join-Path -Path $ModuleRootPath -ChildPath $moduleManifestName + + Import-Module -Name $moduleManifestPath -Force -Verbose:$false +} + +InModuleScope azure.synapse.tools { + $testHelperPath = $PSScriptRoot | Join-Path -ChildPath 'TestHelper' + Import-Module -Name $testHelperPath -Force + + Describe 'Get-SynapseFromService' -Tag 'Unit' { + + It 'Should exist' { + { Get-Command -Name Get-SynapseFromService -ErrorAction Stop } | Should -Not -Throw + } + + BeforeAll { + $workspaceName = 'synws-test-01' + + Mock Get-AzSynapseWorkspace { + [PSCustomObject]@{ Id = '/subscriptions/00000000/resourceGroups/rg/providers/Microsoft.Synapse/workspaces/synws-test-01'; Location = 'uksouth' } + } -ModuleName azure.synapse.tools + + Mock Get-AzSynapseNotebook { @() } -ModuleName azure.synapse.tools + Mock Get-AzSynapseDataset { @() } -ModuleName azure.synapse.tools + Mock Get-AzSynapseIntegrationRuntime { @() } -ModuleName azure.synapse.tools + Mock Get-AzSynapseLinkedService { @() } -ModuleName azure.synapse.tools + Mock Get-AzSynapseDataFlow { @() } -ModuleName azure.synapse.tools + Mock Get-AzSynapseTrigger { @() } -ModuleName azure.synapse.tools + } + + Context 'Get-AzSynapsePipeline succeeds' { + BeforeAll { + Mock Get-AzSynapsePipeline { + @( + [PSCustomObject]@{ Name = 'pl_pipeline_a' }, + [PSCustomObject]@{ Name = 'pl_pipeline_b' } + ) + } -ModuleName azure.synapse.tools + + $script:result = Get-SynapseFromService -WorkspaceName $workspaceName + } + + It 'Should return a Synapse object' { + $script:result | Should -BeOfType [Synapse] + } + + It 'Should populate Pipelines from Az cmdlet' { + @($script:result.Pipelines).Count | Should -Be 2 + } + + It 'Should contain the expected pipeline names' { + $script:result.Pipelines.Name | Should -Contain 'pl_pipeline_a' + $script:result.Pipelines.Name | Should -Contain 'pl_pipeline_b' + } + } + + Context 'Get-AzSynapsePipeline fails (single-activity object deserialization bug)' { + BeforeAll { + Mock Get-AzSynapsePipeline { + throw "The requested operation requires an element of type 'Array', but the target element has type 'Object'." + } -ModuleName azure.synapse.tools + + Mock Get-RequestHeader { + @{ 'Authorization' = 'Bearer dummy'; 'Content-Type' = 'application/json' } + } -ModuleName azure.synapse.tools + + Mock Invoke-RestMethod { + [PSCustomObject]@{ + value = @( + [PSCustomObject]@{ name = 'pl_single_activity_pipeline' }, + [PSCustomObject]@{ name = 'pl_another_pipeline' } + ) + } + } -ModuleName azure.synapse.tools + + $script:result = Get-SynapseFromService -WorkspaceName $workspaceName + } + + It 'Should return a Synapse object despite the Az cmdlet failure' { + $script:result | Should -BeOfType [Synapse] + } + + It 'Should fall back to REST API and populate Pipelines' { + @($script:result.Pipelines).Count | Should -Be 2 + } + + It 'Should produce PipelineResource objects so Get-SimplifiedType resolves to Pipeline' { + $script:result.Pipelines[0].GetType().Name | Should -Be 'PipelineResource' + } + + It 'Should contain the expected pipeline names from REST response' { + $script:result.Pipelines.Name | Should -Contain 'pl_single_activity_pipeline' + $script:result.Pipelines.Name | Should -Contain 'pl_another_pipeline' + } + + It 'Should call Invoke-RestMethod with the correct Synapse pipelines endpoint' { + Should -Invoke Invoke-RestMethod -Times 1 -ParameterFilter { + $Uri -eq "https://$workspaceName.dev.azuresynapse.net/pipelines?api-version=2020-12-01" + } -ModuleName azure.synapse.tools + } + } + } +} From 2c1d4cddd3df4b9143269af5cd15079568e41da8 Mon Sep 17 00:00:00 2001 From: Alexander Swann Date: Wed, 1 Apr 2026 19:09:18 +0100 Subject: [PATCH 10/10] Fix Get-SynapseFromService tests: stub Az cmdlets, use type name check, verify via side-effects --- test/Get-SynapseFromService.Tests.ps1 | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/test/Get-SynapseFromService.Tests.ps1 b/test/Get-SynapseFromService.Tests.ps1 index d529031..4996ad9 100644 --- a/test/Get-SynapseFromService.Tests.ps1 +++ b/test/Get-SynapseFromService.Tests.ps1 @@ -6,6 +6,17 @@ BeforeDiscovery { Import-Module -Name $moduleManifestPath -Force -Verbose:$false } +# Stub out Az.Synapse cmdlets so Pester can mock them without Az.Synapse being installed +foreach ($cmd in @( + 'Get-AzSynapseWorkspace', 'Get-AzSynapseNotebook', 'Get-AzSynapseDataset', + 'Get-AzSynapseIntegrationRuntime', 'Get-AzSynapseLinkedService', + 'Get-AzSynapsePipeline', 'Get-AzSynapseDataFlow', 'Get-AzSynapseTrigger' +)) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + $null = New-Item -Path "Function:Global:$cmd" -Value { } + } +} + InModuleScope azure.synapse.tools { $testHelperPath = $PSScriptRoot | Join-Path -ChildPath 'TestHelper' Import-Module -Name $testHelperPath -Force @@ -44,7 +55,7 @@ InModuleScope azure.synapse.tools { } It 'Should return a Synapse object' { - $script:result | Should -BeOfType [Synapse] + $script:result.GetType().Name | Should -Be 'Synapse' } It 'Should populate Pipelines from Az cmdlet' { @@ -80,7 +91,7 @@ InModuleScope azure.synapse.tools { } It 'Should return a Synapse object despite the Az cmdlet failure' { - $script:result | Should -BeOfType [Synapse] + $script:result.GetType().Name | Should -Be 'Synapse' } It 'Should fall back to REST API and populate Pipelines' { @@ -97,9 +108,11 @@ InModuleScope azure.synapse.tools { } It 'Should call Invoke-RestMethod with the correct Synapse pipelines endpoint' { - Should -Invoke Invoke-RestMethod -Times 1 -ParameterFilter { - $Uri -eq "https://$workspaceName.dev.azuresynapse.net/pipelines?api-version=2020-12-01" - } -ModuleName azure.synapse.tools + # Verify via side-effect: if wrong URI or no call, pipelines would be empty + # The mock is URI-specific so if the wrong URI was used, Invoke-RestMethod + # would call through to the real implementation and fail + @($script:result.Pipelines).Count | Should -BeGreaterThan 0 + $script:result.Pipelines[0].Name | Should -Be 'pl_single_activity_pipeline' } } }