Skip to content

Fix integration suite for module rename; harden repo for public use - #35

Merged
ZanattaMichael merged 17 commits into
mainfrom
add-dsc-v3-support
Aug 11, 2026
Merged

Fix integration suite for module rename; harden repo for public use#35
ZanattaMichael merged 17 commits into
mainfrom
add-dsc-v3-support

Conversation

@ZanattaMichael

Copy link
Copy Markdown
Owner

Summary

Two related pieces of work, both already verified:

Fix the integration suite after the AzureDevOpsDsc -> AzureDevOpsDscNative rename (merged in #34). The rename broke scripts/redeploy-module.ps1 (hardcoded build-output and deploy-target paths) and 60 files under tests/Integration/ that hardcoded the old module name in Import-Module/Import-DscResource/Invoke-DscResource calls. Fixed all of it and verified with a full elevated run against a real org: 381 passed, 0 failed, 22 skipped (~69 min) - matches the last known-clean baseline exactly.

Harden the repo for public use:

  • Added SECURITY.md (private vulnerability reporting via GitHub Security Advisories).
  • Added .github/dependabot.yml for the github-actions ecosystem.
  • Expanded .gitignore for secret-shaped files (*.pfx, *.key, *.pem, *.p12, *.cer, .env*) and OS cruft.
  • Scrubbed the real Azure DevOps org name that had been checked into tests/Integration/TestFrameworkConfiguration.json since Oct 2024. It's now a placeholder template; real values go in the gitignored TestFrameworkConfiguration.local.json (documented in new tests/Integration/README.md).
  • Enabled at the repo-settings level (not part of this diff): secret scanning + push protection, Dependabot alerts + security updates, and branch protection on main (require the Build and Unit Tests status checks, block force-push/deletion - no mandatory PR review, since this is solo-maintained).

Test plan

  • Classes suite: 211/211 (unaffected by this PR, re-confirmed)
  • Common suite: 1703/1703, 10 skipped (unaffected by this PR, re-confirmed)
  • Full integration suite against a real org: 381 passed, 0 failed, 22 skipped (~69 min)

Generated with Claude Code

Azure DevOps Agent added 17 commits August 10, 2026 11:26
Microsoft's Microsoft.Adapter/PowerShell runtime adapter and
DscResource.Authoring's manifest generator both discover class-based DSC
resources via reflection/AST inspection of each class file, which can't see
Set()/Test() when those are implemented purely on a shared base class -
verified empirically that every one of this module's 49 resources was only
advertising "get" capability to DSC v3 tooling, even though Set/Test work
fine when invoked directly.

Adds a direct Set()/Test() override to each resource class (delegating to
AzDevOpsDscResourceBase, replacing the PSScriptAnalyzer suppression that
previously excused their absence) so both the adapter and the manifest
generator correctly detect get/set/test. Verified the delegation is a
functional no-op, including that virtual dispatch to each resource's
overridden hook methods still resolves correctly when called from inside
the base class's Set()/Test().

Adds DscResource.Authoring as a build dependency and a new `dscv3` build
workflow that generates DSC v3 adapted resource manifests for all 49
resources into the build output, wired into `pack` so every packaged
release ships them. Manifests are generated fresh rather than committed,
matching this repo's existing output/ convention.
… to string)

DscResource.Authoring's New-DscAdaptedResourceManifest derives each
property's JSON schema type from the PowerShell AST's TypeName.Name.
For a fully-qualified type annotation like [System.Boolean] or
[System.Int32] - the convention used throughout this module's resource
classes - the AST returns the full dotted name rather than the short
alias ("bool"/"int32") the tool's internal type-mapping switch expects,
so the property silently falls back to its default of "string". This
was masked for [System.String] properties only because "string" happens
to be that same fallback default; verified it affects 25 of the 49
resources (every Boolean- or Int32-typed property).

Adds a new local build task, Fix_DscAdaptedResourceManifestTypes, that
re-derives the correct JSON schema type for every property directly
from the built module's actual class definitions (walking the
inheritance chain), and patches both the individual
*.dsc.adaptedResource.json files and the combined *.dsc.manifests.json
list. Runs last in the dscv3 workflow, since Create_DscResourceManifestsList
regenerates its own copies rather than reading the already-written files.
…test failures)

The integration suite has been consistently failing 4/4 tests in
AzDoNotificationSubscription.tests.ps1 with "A subscription for e-mail
delivery requires a valid e-mail address" across multiple branches and
runs this session. Root cause: New-/Set-AzDoNotificationSubscription
build channel = { type, address } but never set useCustomAddress, so
Azure DevOps silently ignores the custom address and falls back to the
default subscriber's (the calling identity, since subscriber is never
sent - it defaults to the caller) own notification preferences, which
fail validation for any identity without a real mailbox (e.g. the
Managed Identity/service principal these tests authenticate as).

Fixed by setting useCustomAddress = $true whenever a custom address is
supplied, matching Azure DevOps's documented "team subscription"
pattern. Verified via a live elevated re-run that this changes the
error to Azure DevOps's own tenant-membership validation ("the e-mail
address ... does not belong to a user in this organization's Microsoft
Entra ID tenant") - confirming the fix is correct, but that exercising
it end-to-end needs a real user's address in the test org's tenant.

That address is PII and this is a public repository, so - following
the exact pattern already established for AzDoUserEntitlement.tests.ps1
- the integration test now supplies it via the AZDODSC_TEST_USER_UPN
environment variable and skips gracefully when unset, rather than using
a placeholder address that Azure DevOps correctly rejects. Verified the
skip path live (0 passed, 0 failed, 8 skipped, ~1s - no API calls) and
that the previous literal group-name Subscriber value (rejected by the
API on its own terms) has also been replaced, since Subscriber is a
literal channel address by design, not a resolvable group/user identity
(confirmed against New-/Get-AzDoNotificationSubscription's own unit
tests and the resource's documented example).
…anifests

Runs the Sampler build pipeline (build.ps1 -ResolveDependency -Tasks
build) followed by the dscv3 workflow to generate DSC v3 adapted
resource manifests for all 49 resources, uploading the built module
and the manifests as separate, clearly-named build artifacts.

Caches output/RequiredModules (keyed on RequiredModules.psd1's hash) so
repeat runs don't reinstall the full Sampler/DscResource.* dependency
chain, including the DscResource.Authoring prerelease, from PSGallery
every time.

Verified the exact build.ps1 invocations locally end-to-end (including
a fresh -ResolveDependency run) before adding this, and confirmed the
artifact glob patterns match the real generated file layout (49
*.dsc.adaptedResource.json files + the combined *.dsc.manifests.json).
…mitigation

Found two separate, real bugs while investigating the Unit Tests CI
failures visible on the actual GitHub Actions runner:

1. The workflow used `2>&1` to capture test output, but Pester's
   colored summary/pass-fail lines go through Write-Host, which writes
   to the Information stream (6), not Error (2). `2>&1` silently
   dropped them, so $output had no parseable summary and
   classes-tests.log/common-tests.log were never written with real
   content - explaining the "Cannot find path ... because it does not
   exist" error seen in the actual CI run. Fixed by redirecting all
   streams (`*>&1`) and matching each summary value (Passed/Failed/
   Skipped) independently rather than requiring all three on what
   $output treats as a single "line" (Write-Host segments can land in
   separate array elements).

2. Re-confirmed the known Pester v5 class-loading race ("Could not find
   type [X]") reproduces reliably under CPU-constrained conditions
   (both a nested child pwsh process locally and the actual GitHub
   Actions runner - which failed on its very first, only attempt).
   Retries don't help since it isn't a random race, it's tied to how
   "cold"/constrained the host is. Repeated cold-host reproductions
   showed a short pause after class-loading, before Invoke-Pester
   starts, reliably avoids it - added to azuredevopsdsc.tests.ps1.
Revert the disproven 2-second delay in azuredevopsdsc.tests.ps1 (confirmed
on GH Actions run 31430023653 to NOT prevent the class-loading race - same
11 failures as baseline).

Add a temporary diagnostic to build.yml: run a Pester test file that uses
`using module` (not Import-Module) against the genuinely built module's
psd1, checking whether bare type-literal resolution works reliably for the
classes that fail intermittently in the real suite. Based on external
research (deadlydog/PowerShell.Experiment.ClassInModule) indicating classes
defined in one physical psm1 (which ModuleBuilder already produces) plus
`using module` (not Import-Module, which was already disproven) is the only
combination that reliably supports external bare type-literal resolution.
DscResource.Common (which provides Get-LocalizedData, called at the top
of the built AzureDevOpsDsc.Common.psm1) lives in output/RequiredModules
after -ResolveDependency, but the diagnostic step's fresh pwsh session
didn't have it on PSModulePath, causing an unrelated command-not-found
error unrelated to the using-module class-resolution theory being tested.
Confirms whether 'using module AzureDevOpsDsc' (resolved via PSModulePath,
auto-selecting the version subfolder) works as well as a hardcoded
versioned path - needed since the built module's version changes over
time and using-module paths can't be computed dynamically at parse time.
…lt module

Root cause (confirmed via external research on deadlydog/PowerShell.Experiment.ClassInModule,
then validated live on GitHub Actions): dot-sourcing PowerShell class definitions does not
reliably survive Pester v5's per-block session-state rebinding, intermittently producing
"Could not find type [X]" for classes referenced via bare type-literal syntax inside It/BeforeAll
blocks on cold runners. This is not a timing/race issue - it's a fundamental limitation of
dot-sourced (and Import-Module'd) classes never being reliably resolvable via bare type literals
outside their original scope. `using module` is the one mechanism that reliably works, but only
against a module whose classes are physically defined in one psm1 - which is exactly what
ModuleBuilder already produces for the built module.

Changes:
- All 19 Classes-suite test files now start with `using module AzureDevOpsDsc` (resolved via
  PSModulePath, auto-picking the built module's version folder) instead of dot-sourcing raw
  source class/enum files into their own scope on every run.
- azuredevopsdsc.tests.ps1 no longer dot-sources classes/enums itself (test files now source
  classes from the built module instead); Import-ClassesAndEnums.ps1 is now dead code and removed.
- unit-tests.yml now builds the module (with RequiredModules caching, mirroring build.yml) before
  running the Classes suite, and sets PSModulePath to include output/RequiredModules and
  output/builtModule before Pester parses any test file - required since `using module`
  resolves at parse time, before any of the test file's own runtime code executes.

Validated on real GitHub Actions runs (31431577660, 31431809051): a diagnostic Pester file using
`using module` against the built module resolved all 10 previously-failing type literals 10/10,
after two prior candidate fixes (an explicit delay, and plain Import-Module of the built module)
were each disproven on real runs.
…module path

The first using-module pass only touched the 19 files that had the old
dot-sourcing reload guard, but many other Classes-suite files (AzDevOps*Base
tests, PersonalAccessToken, AzDoGroupPermission) relied solely on the
orchestrator's own class dot-sourcing, which was removed - breaking them.
Add `using module AzureDevOpsDsc` to all 20 remaining files.

Also fix a second, distinct gap surfaced by that same CI run: several DSC
resource classes' Construct() explicitly calls `Import-Module
AzureDevOpsDsc.Common` by bare name, which only resolves via PSModulePath -
it isn't found via output\builtModule itself, since the nested module lives
one level down at output\builtModule\AzureDevOpsDsc\<version>\Modules. Add
that directory to PSModulePath in unit-tests.yml, resolved dynamically via
Get-ChildItem to avoid hardcoding the built module's version.
… tests

Two more issues surfaced by switching the Classes suite to `using module`:

1. Token factory functions (New-ManagedIdentityToken, New-ServicePrincipalToken,
   New-CertificateToken, New-CertificateTokenFromFile, New-AzureCliToken,
   New-WorkloadIdentityFederationToken, New-PersonalAccessToken) were declared
   with the `global:` scope modifier. That was only ever needed because the old
   test harness dot-sourced each class file inside a ForEach-Object scriptblock
   (a transient child scope), and `global:` was how the function survived past
   that loop. It's dead weight now that classes come from `using module` - and
   actively harmful, since a `global:`-scoped function loses its binding to the
   module's own class type table, so `[ManagedIdentityToken]::New(...)` inside
   it threw "Could not find type" even though `using module` had otherwise
   worked. Removed `global:` from all 7 declarations; they're still exported
   normally since the manifest's FunctionsToExport is unset (defaults to '*').

2. Class methods (Construct/Get/Set/Test) now execute inside the real built
   module's own scope rather than the test file's scope, so Pester's `Mock`
   - scoped to the caller by default - no longer intercepts calls those
   methods make to Test-Path, Import-Clixml, Import-Module, and the various
   AzDoXXX/New-AzDoAuthenticationProvider/Get-AzDoCacheObjects helpers. Added
   `-ModuleName AzureDevOpsDsc` to every Mock/Assert-MockCalled call across the
   13 Resources test files that mock class-internal dependencies, so the mocks
   are visible from within the module's session state.
…zureDevOpsDsc

Test-AzDevOpsProjectName is a private, never-exported helper inside
AzureDevOpsDsc.Common (confirmed via that module's dynamic-loader psm1: it
only auto-exports functions under a \Public\ path, plus a small hardcoded
Export-ModuleMember list - Test-AzDevOpsProjectName is neither). It's
genuinely unresolvable from AzureDevOpsDsc's own module scope, so
`Mock -CommandName Test-AzDevOpsProjectName -ModuleName AzureDevOpsDsc`
fails at registration time with a CommandNotFoundException - which was
aborting the entire BeforeAll (and therefore every It in that Describe) in
020.AzDoProject, 050.AzDoWIPTags, 051.AzDoAreaNodes, and 052.AzDoIterationNodes.
Since Test-AzDevOpsProjectName is only ever called from other
AzureDevOpsDsc.Common functions (e.g. Get-AzDoProject's own
[ValidateScript] on -ProjectName), not from class methods directly, the
correct scope for this specific mock is -ModuleName AzureDevOpsDsc.Common.

Brings the Classes suite from 190 passed/21 failed to 198 passed/13 failed
locally - WIPTags/AreaNodes/IterationNodes now pass in full.
…ing-module classes

Two final root causes for the last 13 Classes-suite failures:

1. Export-ModuleMember gap: Test-AzDevOpsProjectName and ConvertTo-Base64String
   are private helpers in AzureDevOpsDsc.Common, called directly from class
   constructors/methods now living in the AzureDevOpsDsc root module's own
   scope (via `using module`). Neither was covered by the Public-path
   auto-export or the module's hardcoded Export-ModuleMember list, so they
   were genuinely unresolvable cross-module - not a test artifact, a real gap
   that would also bite production use of PersonalAccessToken and any
   AzDoProject-family resource outside of a call chain that happens to import
   Common first. Exporting both fixes this for real callers too.

2. `Should -BeOfType [ClassName]` / `Should -BeOfType 'ClassName'`: Pester's
   own implementation re-resolves the type by name from within Pester's own
   module scope, which has no visibility into a class only bound via the
   *test file's* `using module` statement - a documented Pester limitation
   with PowerShell classes. Every remaining failure (all 11, exactly matching
   the original flaky-11 list) was a `-BeOfType` assertion, not a real
   construction failure - direct `[ClassName]::new()` calls always worked
   fine. Replaced with `($value -is [ClassName]) | Should -BeTrue`, which
   evaluates the type check in the test file's own scope instead.

Classes suite now passes 211/211, deterministically, both locally and
matching the real GitHub Actions runner's behavior for every fix verified
so far in this branch.
The Classes-suite fix unblocked the Common suite from running in CI for the
first time. That surfaced ~29 pre-existing failures, unrelated to the class-
loading work, spanning several distinct root causes:

- 17 permission functions (AzDoAgentPoolPermission, AzDoEnvironmentPermission,
  AzDoPipelinePermission x4, AzDoProcessPermission x4, AzDoProjectPermission
  x4, AzDoSecurityNamespacePermission, AzDoServiceConnectionPermission,
  AzDoVariableGroupPermission) call Write-Error as a non-fatal "log a
  diagnostic and return a graceful Error status" pattern - but that becomes
  terminating under this runner's ambient $ErrorActionPreference, aborting
  the function before it returns. Added -ErrorAction Continue to make the
  intent explicit regardless of caller preference (confirmed: passed locally,
  failed on the real GH Actions runner, matching this exact mechanism).

- List-DevOpsAgentPools: `@($defaultPools) + @($deploymentPools)` produces a
  2-element array of nulls (not empty) when both API calls return no data,
  since @($null) wraps a single null into a 1-element array. Filiter out
  nulls from the merged result.

- Build-JWTAssertion: $Certificate.GetRSAPrivateKey() is an extension method
  PowerShell's member-access syntax can fail to resolve depending on
  assembly load order; call it via its fully-qualified static form instead.

- Get-AzCliToken: relied on reading $LASTEXITCODE in the caller's scope after
  calling a mocked wrapper function - not reliably preserved across a Pester
  mock boundary. Have Invoke-AzCLICommand capture and return ExitCode
  explicitly instead of leaving it to the ambient global variable.

- Test-Date: used culture-dependent `-as [DateTime]` conversion, so an
  unambiguous dd/MM/yyyy date fails to parse on any MM/dd/yyyy-default
  culture (e.g. en-US, GitHub's hosted runner default) even though callers
  accept both slash conventions. Try each accepted format explicitly,
  culture-invariant.

- Test-ACLListforChanges.tests.ps1 had a stale assertion expecting NotFound
  for a null Difference ACL; the source was already deliberately changed to
  return Changed instead (see its own comment) - the test was never updated
  to match.

- ~10 tests across Get-AzDoAgentPool/AgentQueue/Extension/PipelineEnvironment,
  Resolve-DevOpsProcess, ConvertTo-DevOpsServiceHookSubscription, New-ACLToken,
  and Get-AzDoSecurityNamespacePermission were missing an explicit dot-source
  for a real dependency (List-DevOpsAgentPools/Queues/Extensions/
  PipelineEnvironments, the [CacheItem] class, Get-AzDoCacheObjects,
  Resolve-AzDoProjectIdForToken, New-ACLToken/ConvertTo-FormattedToken) that
  the function under test calls internally but the test file's own
  Find-MockedFunctions-based lazy-loading never picks up since it's not
  directly mocked in that file.

- Get-AzServicePrincipalCertificateToken.tests.ps1 mocked a certificate-store
  lookup with a fake PSCustomObject standing in for an [X509Certificate2]-
  typed parameter; Mock preserves the real function's parameter type
  constraint even though its body is faked, so PowerShell's own argument
  type coercion failed before the mock ever ran. Use a real (trivial,
  self-signed) certificate instead.

Verified locally: Common suite now passes 1703/1703 (10 skipped), up from
1674 passed/29 failed/10 skipped.
…source

Same gap as the other Get-CacheItem-mocking test files fixed in the
previous commit - missed this one. It passed in the local full-suite run by
accident (an earlier-discovered file's own dot-sourcing happened to mask
the gap), but failed on the real GitHub Actions runner where that ordering
coincidence didn't hold. Swept the rest of the Common suite for the same
pattern (Mock -CommandName Get-CacheItem without Get-AzDoCacheObjects
dot-sourced) - no other instances found.
…flow

Renaming to publish under this fork's own identity:
- PowerShell Gallery names are globally unique and this repo doesn't own the
  existing 'AzureDevOpsDsc' listing (that's the upstream dsccommunity project).
- 'AzureDevOpsDscv3' - the obvious next choice - is already taken by a
  different community fork (github.com/mimachniak/AzureDevOpsDscv3), which
  also already publishes to PSGallery under that exact name (case-insensitive
  collision). Its approach differs from this fork's anyway (a
  Microsoft.Windows/WindowsPowerShell wrapper vs. this fork's native
  Microsoft.Adapter/PowerShell + generated adapted resource manifests), so
  'AzureDevOpsDscNative' names the actual differentiator instead of just
  restating "also DSC v3".

Renamed source/AzureDevOpsDsc.psd1 -> source/AzureDevOpsDscNative.psd1 (new
GUID, updated Author/CompanyName/Description/LicenseUri/ProjectUri), renamed
the en-US localized-data and about-help files to match (Get-LocalizedData
looks up a file named after the calling module), updated all 39 Classes-suite
test files' `using module AzureDevOpsDsc` statements and 96
`Mock -ModuleName AzureDevOpsDsc` references (leaving the AzureDevOpsDsc.Common
nested submodule name untouched - that's an internal implementation detail,
not the public package identity), and updated unit-tests.yml's PSModulePath
resolution accordingly. Verified locally after the rename: Classes suite
211/211, Common suite 1703/1703 (10 skipped) - unchanged from before the
rename.

Added .github/workflows/publish.yml: triggers on version tags (vX.Y.Z, tags
containing a hyphen like pre-release tags excluded) or manual dispatch.
Derives ModuleVersion from the tag, builds, re-runs both unit test suites as
a release gate (refuses to publish if either has a failure, even though
tagging main implies it already passed once - a tag can point anywhere),
generates DSC v3 manifests, packages the module, and publishes a GitHub
Release plus - once a Gallery API key is configured - PowerShell Gallery,
via Sampler's existing publish task chain (each sub-task self-guards on its
own token being present, so it degrades gracefully rather than failing hard
if GALLERY_API_TOKEN isn't set yet).

Updated README.md (new title/description, framed as a fork with its actual
differentiator, replaced the stale "look at mimachniak's project instead"
note - now that this fork carries substantial DSC v3 support and fixes of
its own - with a reference alongside it instead) and CHANGELOG.md (title,
plus new Added/Fixed entries covering the rename, the new workflows, and
this session's test-suite fixes).
Integration suite fixes (required for the full run to work at all after the
AzureDevOpsDsc -> AzureDevOpsDscNative rename):
- scripts/redeploy-module.ps1: build-output source path and deploy-target
  path both hardcoded the old module name.
- 60 files under tests/Integration/: Import-Module/Import-DscResource/
  Invoke-DscResource references to 'AzureDevOpsDsc', including
  tests/Integration/Supporting/Initalize-TestFramework.ps1's
  `Import-Module AzureDevOpsDsc.psd1` (kept AzureDevOpsDsc.Common.psd1
  untouched - that submodule wasn't renamed).
Verified via a full elevated run against a real org: 381 passed, 0 failed,
22 skipped (~69 min) - matches the prior clean baseline exactly.

Hardening for public use:
- Added SECURITY.md (private vulnerability reporting via GitHub Security
  Advisories, scoped to this fork's own changes vs. upstream).
- Added .github/dependabot.yml for the github-actions ecosystem (PowerShell
  Gallery modules aren't a supported Dependabot ecosystem, so
  RequiredModules.psd1 isn't covered by this - only Action version pins are).
- Expanded .gitignore: common secret-shaped file patterns (*.pfx, *.key,
  *.pem, *.p12, *.cer, .env*) and OS cruft (.DS_Store, Thumbs.db, etc).
- Scrubbed the real Azure DevOps org name that had been checked into
  tests/Integration/TestFrameworkConfiguration.json since Oct 2024 (not a
  credential, but an identifying-info leak). The tracked file is now a
  placeholder template; real values go in TestFrameworkConfiguration.local.json
  (gitignored via the existing *.local.* pattern, never committed). Added
  tests/Integration/README.md documenting the convention.
- Enabled (via repo settings, not in this diff): secret scanning + push
  protection, Dependabot alerts + security updates, and branch protection on
  main (require Build + Unit Tests status checks, block force-push and
  branch deletion - no mandatory PR review, to avoid locking out a solo
  maintainer's own merges).
@ZanattaMichael
ZanattaMichael merged commit 1c8b1b1 into main Aug 11, 2026
4 checks passed
@ZanattaMichael
ZanattaMichael deleted the add-dsc-v3-support branch August 11, 2026 23:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant