From f6a84b171163c36d5a3622077aefb9b223be5288 Mon Sep 17 00:00:00 2001 From: Roland Krummenacher Date: Wed, 5 Aug 2026 18:08:40 +0200 Subject: [PATCH 1/2] test(hubs): add regression test for ADF trigger time zone IDs Data Factory schedule triggers validate the timeZone field against Windows time zone IDs; an invalid value (e.g. the display name 'Japan Standard Time' instead of the Windows ID 'Tokyo Standard Time', fixed in #2236) passes deployment but fails at trigger activation. Adds a Pester test that parses timeZones.bicep and verifies every mapping resolves in the Windows time zone registry with exact casing, rejects IANA-style and display-name IDs, checks region keys against open data, and pins the 'UTC' fallback. Extends the PowerShell Tests workflow paths to trigger on timeZones.bicep changes. Co-Authored-By: Claude Fable 5 --- .github/workflows/dev.yml | 2 + .../Unit/HubsAdfTriggerTimeZones.Tests.ps1 | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 33fc20309..14f2b7591 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -7,6 +7,8 @@ on: # KQL sources are covered by unit tests (HubsKqlOperators.Tests.ps1) - 'src/templates/finops-hub/**/*.kql' - 'src/queries/**/*.kql' + # ADF trigger time zone mappings are covered by unit tests (HubsAdfTriggerTimeZones.Tests.ps1) + - 'src/templates/finops-hub/**/timeZones.bicep' jobs: run_pester_tests: name: Pester diff --git a/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 b/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 new file mode 100644 index 000000000..2321eab21 --- /dev/null +++ b/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Regression coverage for the ADF schedule trigger time zone mappings (PR #2236): + + Data Factory schedule triggers validate the timeZone field against Windows time zone IDs + (the registry IDs enumerated by [TimeZoneInfo]::GetSystemTimeZones() / Get-TimeZone -ListAvailable). + A display name or IANA-style ID (e.g. 'Japan Standard Time' instead of the Windows ID + 'Tokyo Standard Time') passes deployment but fails at trigger activation with: + ErrorCode=InvalidWorkflowTriggerRecurrence, ErrorMessage=The recurrence of trigger has an invalid time zone '...'. + See: https://learn.microsoft.com/azure/data-factory/how-to-create-schedule-trigger#time-zone-option + + These tests parse timeZones.bicep and verify: + 1. Every mapped value resolves in the Windows time zone registry, with exact casing (Windows runners only). + 2. No value is an IANA-style ID (contains '/'), which .NET on Linux would accept but ADF rejects. + 3. Every region key matches the lookup normalization (lowercase, no spaces) and is a known Azure region. + 4. The fallback stays pinned to the valid Windows ID 'UTC' (not a display name). +#> + +Describe 'HubsAdfTriggerTimeZones' { + + BeforeDiscovery { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $bicepPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/timeZones.bicep' + $bicepLines = Get-Content -Path $bicepPath + + # Extract the region -> time zone entries from the timezoneobject param block. + $mappings = @() + $inBlock = $false + foreach ($line in $bicepLines) + { + if ($line -match '^param timezoneobject object = \{') { $inBlock = $true; continue } + if ($inBlock -and $line -match '^\}') { break } + if ($inBlock -and $line -match "^\s*([^\s:/]+)\s*:\s*'([^']*)'") + { + $mappings += @{ Region = $Matches[1]; TimeZoneId = $Matches[2] } + } + } + + $distinctTimeZones = @($mappings | ForEach-Object { $_.TimeZoneId } | Sort-Object -Unique | ForEach-Object { @{ TimeZoneId = $_ } }) + } + + BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $bicepPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/timeZones.bicep' + $bicepContent = Get-Content -Path $bicepPath -Raw + + # Known Azure region IDs from toolkit open data. Loaded in BeforeAll (run phase) because + # BeforeDiscovery variables are not visible inside It blocks at run time. + $regionIds = @{} + Import-Csv (Join-Path $repoRoot 'src/open-data/Regions.csv') | ForEach-Object { $regionIds[$_.RegionId] = $true } + } + + Context 'Parsing' { + It 'Finds the timezoneobject mappings in timeZones.bicep' -TestCases @(@{ MappingCount = @($mappings).Count }) { + # Guards the parser itself: if the bicep layout changes, fail loudly instead of green-running zero cases. + # Count is captured at discovery time (BeforeDiscovery variables are not visible at run time). + $MappingCount | Should -BeGreaterThan 30 + } + } + + Context 'Windows time zone registry' { + It 'Maps to a Windows time zone ID that exists in the registry: ' -TestCases $mappings -Skip:(-not $IsWindows) { + # Get-TimeZone -Id emits a non-terminating error for unknown IDs; -ErrorAction Stop makes it throw. + $resolved = Get-TimeZone -Id $TimeZoneId -ErrorAction Stop + # Exact casing: the registry lookup is case-insensitive, but pin the canonical ID so the + # file never drifts from what ADF documents and the portal dropdown emits. + $resolved.Id | Should -BeExactly $TimeZoneId + } + } + + Context 'Time zone ID format' { + It 'Value "" is not an IANA-style or display-name ID' -TestCases $distinctTimeZones { + $TimeZoneId | Should -Not -BeNullOrEmpty + # IANA IDs (Area/Location) resolve in .NET on Linux but are rejected by ADF trigger activation. + $TimeZoneId | Should -Not -Match '/' + # Known-bad display names that read like valid IDs but are not in the Windows registry. + $displayNames = @('Japan Standard Time', 'Universal Coordinated Time', 'Coordinated Universal Time') + $displayNames | Should -Not -Contain $TimeZoneId + } + } + + Context 'Region keys' { + It 'Region key "" matches the toLower/no-space lookup normalization' -TestCases $mappings { + # timeZones.bicep resolves timezoneobject[toLower(replace(location, ' ', ''))]. + $Region | Should -BeExactly $Region.ToLowerInvariant() + $Region | Should -Not -Match '\s' + } + + It 'Region key "" is a known Azure region' -TestCases $mappings { + $regionIds.ContainsKey($Region) | Should -BeTrue -Because "region '$Region' should exist in src/open-data/Regions.csv" + } + } + + Context 'Fallback' { + It "Pins the fallback to the valid Windows ID 'UTC'" { + # 'UTC' is a real Windows time zone ID; the display name 'Universal Coordinated Time' is not. + $bicepContent | Should -Match "\?\?\s*'UTC'" + } + } +} From 91ce5a5e57c66f29418614be1d5d871fcfc4ff5f Mon Sep 17 00:00:00 2001 From: Roland Krummenacher Date: Wed, 5 Aug 2026 18:20:41 +0200 Subject: [PATCH 2/2] fix(finops-hub): correct invalid trigger time zone IDs in shipped deploy templates The compiled templates in docs/deploy are regenerated at release time and lagged the src fixes: finops-hub-latest.json and finops-hub-14.0.json still mapped Japan regions to the display name 'Japan Standard Time', and finops-hub-preview.json shipped a whole set of pre-fix display names (Australian Eastern/Brasil/Central European/Greenwich Mean) that fail ADF trigger activation. Surgically corrects the mapping values to the Windows time zone IDs matching the current src mapping (northeurope goes to 'GMT Standard Time' per src, not W. Europe). Extends HubsAdfTriggerTimeZones.Tests.ps1 to also validate the mappings in finops-hub-latest.json and finops-hub-preview.json so shipped artifacts can no longer drift invalid, and adds those paths to the Pester workflow triggers. Raised in the PR #2236 review discussion. Co-Authored-By: Claude Fable 5 --- .github/workflows/dev.yml | 2 + docs/deploy/finops-hub-14.0.json | 8 ++-- docs/deploy/finops-hub-latest.json | 8 ++-- docs/deploy/finops-hub-preview.json | 32 +++++++------- .../Unit/HubsAdfTriggerTimeZones.Tests.ps1 | 43 +++++++++++++++++++ 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 14f2b7591..0ea6747d7 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -9,6 +9,8 @@ on: - 'src/queries/**/*.kql' # ADF trigger time zone mappings are covered by unit tests (HubsAdfTriggerTimeZones.Tests.ps1) - 'src/templates/finops-hub/**/timeZones.bicep' + - 'docs/deploy/finops-hub-latest.json' + - 'docs/deploy/finops-hub-preview.json' jobs: run_pester_tests: name: Pester diff --git a/docs/deploy/finops-hub-14.0.json b/docs/deploy/finops-hub-14.0.json index f9d71a4ab..5d526c052 100644 --- a/docs/deploy/finops-hub-14.0.json +++ b/docs/deploy/finops-hub-14.0.json @@ -17311,8 +17311,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", @@ -26318,8 +26318,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", diff --git a/docs/deploy/finops-hub-latest.json b/docs/deploy/finops-hub-latest.json index f9d71a4ab..5d526c052 100644 --- a/docs/deploy/finops-hub-latest.json +++ b/docs/deploy/finops-hub-latest.json @@ -17311,8 +17311,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", @@ -26318,8 +26318,8 @@ "francecentral": "W. Europe Standard Time", "germanynorth": "W. Europe Standard Time", "germanywestcentral": "W. Europe Standard Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", diff --git a/docs/deploy/finops-hub-preview.json b/docs/deploy/finops-hub-preview.json index 394756163..1ac3fa3dd 100644 --- a/docs/deploy/finops-hub-preview.json +++ b/docs/deploy/finops-hub-preview.json @@ -3894,9 +3894,9 @@ "timezoneobject": { "type": "object", "defaultValue": { - "australiaeast": "Australian Eastern Standard Time", - "australiasoutheast": "Australian Eastern Standard Time", - "brazilsouth": "Brasil Standard Time", + "australiaeast": "AUS Eastern Standard Time", + "australiasoutheast": "AUS Eastern Standard Time", + "brazilsouth": "E. South America Standard Time", "canadacentral": "Central Standard Time", "canadaeast": "Eastern Standard Time", "centralindia": "India Standard Time", @@ -3904,26 +3904,26 @@ "eastasia": "China Standard Time", "eastus": "Eastern Standard Time", "eastus2": "Eastern Standard Time", - "francecentral": "Central European Time", - "germanynorth": "Central European Time", - "germanywestcentral": "Central European Time", - "japaneast": "Japan Standard Time", - "japanwest": "Japan Standard Time", + "francecentral": "W. Europe Standard Time", + "germanynorth": "W. Europe Standard Time", + "germanywestcentral": "W. Europe Standard Time", + "japaneast": "Tokyo Standard Time", + "japanwest": "Tokyo Standard Time", "koreacentral": "Korea Standard Time", "koreasouth": "Korea Standard Time", "northcentralus": "Central Standard Time", - "northeurope": "Central European Time", - "norwayeast": "Central European Time", - "norwaywest": "Central European Time", + "northeurope": "GMT Standard Time", + "norwayeast": "W. Europe Standard Time", + "norwaywest": "W. Europe Standard Time", "southcentralus": "Central Standard Time", "southindia": "India Standard Time", "southeastasia": "Singapore Standard Time", - "switzerlandnorth": "Central European Time", - "switzerlandwest": "Central European Time", - "uksouth": "Greenwich Mean Time", - "ukwest": "Greenwich Mean Time", + "switzerlandnorth": "W. Europe Standard Time", + "switzerlandwest": "W. Europe Standard Time", + "uksouth": "GMT Standard Time", + "ukwest": "GMT Standard Time", "westcentralus": "Central Standard Time", - "westeurope": "Central European Time", + "westeurope": "W. Europe Standard Time", "westindia": "India Standard Time", "westus": "Pacific Standard Time", "westus2": "Pacific Standard Time" diff --git a/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 b/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 index 2321eab21..fcba2a4b4 100644 --- a/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 +++ b/src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1 @@ -16,6 +16,11 @@ 2. No value is an IANA-style ID (contains '/'), which .NET on Linux would accept but ADF rejects. 3. Every region key matches the lookup normalization (lowercase, no spaces) and is a known Azure region. 4. The fallback stays pinned to the valid Windows ID 'UTC' (not a display name). + + The same value checks also run against the compiled deployment templates users actually deploy from + (docs/deploy/finops-hub-latest.json and finops-hub-preview.json), because those are regenerated at + release time and can lag a src fix (see the #2236 review discussion). Older versioned templates are + historical artifacts and intentionally not covered. #> Describe 'HubsAdfTriggerTimeZones' { @@ -39,6 +44,32 @@ Describe 'HubsAdfTriggerTimeZones' { } $distinctTimeZones = @($mappings | ForEach-Object { $_.TimeZoneId } | Sort-Object -Unique | ForEach-Object { @{ TimeZoneId = $_ } }) + + # Extract the same mapping from the compiled deployment templates (shipped artifacts). + # Each template embeds the timezoneobject param default in nested deployments; the blocks are + # identical, so collapse to unique (File, Region, TimeZoneId) tuples. + $deployTemplates = @('finops-hub-latest.json', 'finops-hub-preview.json') + $deployMappings = @() + $deployBlockCounts = @() + foreach ($templateName in $deployTemplates) + { + $templateContent = Get-Content -Path (Join-Path $repoRoot "docs/deploy/$templateName") -Raw + $blocks = [regex]::Matches($templateContent, '"timezoneobject"\s*:\s*\{\s*"type"\s*:\s*"object",\s*"defaultValue"\s*:\s*\{([^}]*)\}') + $deployBlockCounts += @{ File = $templateName; BlockCount = $blocks.Count } + $seen = @{} + foreach ($block in $blocks) + { + foreach ($pair in [regex]::Matches($block.Groups[1].Value, '"([^"]+)"\s*:\s*"([^"]*)"')) + { + $key = $pair.Groups[1].Value + '|' + $pair.Groups[2].Value + if (-not $seen.ContainsKey($key)) + { + $seen[$key] = $true + $deployMappings += @{ File = $templateName; Region = $pair.Groups[1].Value; TimeZoneId = $pair.Groups[2].Value } + } + } + } + } } BeforeAll { @@ -58,6 +89,11 @@ Describe 'HubsAdfTriggerTimeZones' { # Count is captured at discovery time (BeforeDiscovery variables are not visible at run time). $MappingCount | Should -BeGreaterThan 30 } + + It 'Finds the timezoneobject blocks in ' -TestCases $deployBlockCounts { + # Same guard for the compiled templates: layout changes must not silently drop coverage. + $BlockCount | Should -BeGreaterThan 0 + } } Context 'Windows time zone registry' { @@ -70,6 +106,13 @@ Describe 'HubsAdfTriggerTimeZones' { } } + Context 'Compiled deployment templates' { + It ': maps to a Windows time zone ID that exists in the registry: ' -TestCases $deployMappings -Skip:(-not $IsWindows) { + $resolved = Get-TimeZone -Id $TimeZoneId -ErrorAction Stop + $resolved.Id | Should -BeExactly $TimeZoneId + } + } + Context 'Time zone ID format' { It 'Value "" is not an IANA-style or display-name ID' -TestCases $distinctTimeZones { $TimeZoneId | Should -Not -BeNullOrEmpty