:8082/token.svc`)
+ - Content / GraphQL URL returned by Discovery
diff --git a/test/New-CmTestDockerfile.ps1 b/test/New-CmTestDockerfile.ps1
new file mode 100644
index 0000000..9898511
--- /dev/null
+++ b/test/New-CmTestDockerfile.ps1
@@ -0,0 +1,137 @@
+<#
+.SYNOPSIS
+ Copies the Example WebApp Dockerfile (net8 or net10), points Discovery at a CM/CD IP,
+ then builds the image from the repo root if DXA runtime ports are open.
+ The generated Dockerfile is gitignored and must not be committed.
+
+.PARAMETER Framework
+ Target TFM Dockerfile: net8 (Dockerfile) or net10 (Dockerfile.net10.0).
+
+.PARAMETER CmServerIp
+ IPv4 or IPv6 address of the combined CM/CD host (not a hostname).
+
+.EXAMPLE
+ .\test\New-CmTestDockerfile.ps1 -Framework net8 -CmServerIp 192.0.2.10
+
+.EXAMPLE
+ .\test\New-CmTestDockerfile.ps1 -Framework net10 -CmServerIp 192.0.2.10
+#>
+[CmdletBinding()]
+param(
+ [ValidateSet('net8', 'net10')]
+ [string]$Framework,
+
+ [Parameter(Mandatory = $true)]
+ [string]$CmServerIp
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$RepoRoot = Split-Path -Parent $PSScriptRoot
+
+$frameworkValues = @('net8', 'net10')
+if ([string]::IsNullOrWhiteSpace($Framework)) {
+ Write-Host ("Available Framework values: {0}" -f ($frameworkValues -join ', ')) -ForegroundColor Cyan
+ $Framework = Read-Host 'Framework'
+}
+if ($frameworkValues -notcontains $Framework) {
+ throw ("Framework must be one of: {0}. Got: '{1}'" -f ($frameworkValues -join ', '), $Framework)
+}
+
+$parsedIp = $null
+if (-not [System.Net.IPAddress]::TryParse($CmServerIp, [ref]$parsedIp)) {
+ throw "CmServerIp must be an IP address. Got: '$CmServerIp'"
+}
+
+$testPorts = Join-Path $PSScriptRoot 'Test-DxaRuntimePorts.ps1'
+Write-Host "Checking DXA runtime ports..." -ForegroundColor Cyan
+$runtimePortsOpen = & $testPorts -CmServerIp $CmServerIp -DxaRuntimeOnly -PassThru
+if (-not $runtimePortsOpen) {
+ Write-Host "Skipping docker build because DXA runtime ports are not open." -ForegroundColor Red
+ exit 1
+}
+
+$webAppDir = Join-Path $RepoRoot 'dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp'
+$discoveryUrl = "http://${CmServerIp}:8082/discovery.svc"
+$appsettingsRel = 'dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/appsettings.json'
+
+if ($Framework -eq 'net8') {
+ $sourceName = 'Dockerfile'
+ $destName = 'Dockerfile.cmtest'
+ $imageTag = 'dxa-example-webapp:net8-cmtest'
+}
+else {
+ $sourceName = 'Dockerfile.net10.0'
+ $destName = 'Dockerfile.net10.0.cmtest'
+ $imageTag = 'dxa-example-webapp:net10-cmtest'
+}
+
+$sourcePath = Join-Path $webAppDir $sourceName
+$destPath = Join-Path $webAppDir $destName
+
+if (-not (Test-Path -LiteralPath $sourcePath)) {
+ throw "Source Dockerfile not found: $sourcePath"
+}
+
+$content = Get-Content -LiteralPath $sourcePath -Raw
+
+$generatedHeader = @"
+# GENERATED by New-CmTestDockerfile.ps1 — do not commit
+# Build from repo root:
+# docker build -f dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/$destName -t $imageTag .
+
+"@
+
+$copyNeedle = "COPY dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ ./dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/"
+$sedInsert = @"
+$copyNeedle
+
+# Point Discovery at the CM/CD test host (replaces localhost in appsettings.json)
+RUN sed -i 's#http://localhost:8082/discovery.svc#$discoveryUrl#g' $appsettingsRel
+"@
+
+if ($content -notlike "*$copyNeedle*") {
+ throw "Unexpected Dockerfile layout; could not find WebApp COPY line in $sourceName"
+}
+
+$content = $content.Replace($copyNeedle, $sedInsert)
+
+$envNeedle = "ENV Logging__LogLevel__Default=Warning"
+$envInsert = @"
+$envNeedle
+ENV Dxa__Services__Discovery=$discoveryUrl
+"@
+
+if ($content -notlike "*$envNeedle*") {
+ throw "Unexpected Dockerfile layout; could not find Logging ENV in $sourceName"
+}
+
+$content = $content.Replace($envNeedle, $envInsert)
+
+# Drop the original "Build from repo root" comment block; generated header replaces it.
+$content = $content -replace '(?s)^# Build from repo root:.*?(?=# Use official)', ''
+
+$utf8NoBom = New-Object System.Text.UTF8Encoding $false
+[System.IO.File]::WriteAllText($destPath, $generatedHeader + $content.TrimStart(), $utf8NoBom)
+
+Write-Host "Wrote $destPath" -ForegroundColor Green
+Write-Host "Discovery: $discoveryUrl"
+Write-Host ""
+
+$dockerFileRel = "dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/$destName"
+Write-Host "Running docker build from repo root..." -ForegroundColor Cyan
+Write-Host " docker build -f $dockerFileRel -t $imageTag ."
+
+Push-Location $RepoRoot
+try {
+ & docker build -f $dockerFileRel -t $imageTag .
+ if ($LASTEXITCODE -ne 0) {
+ throw "docker build failed with exit code $LASTEXITCODE"
+ }
+}
+finally {
+ Pop-Location
+}
+
+Write-Host "Image tagged $imageTag" -ForegroundColor Green
diff --git a/test/Open-CmTestFirewallPorts.ps1 b/test/Open-CmTestFirewallPorts.ps1
new file mode 100644
index 0000000..9dd8dbb
--- /dev/null
+++ b/test/Open-CmTestFirewallPorts.ps1
@@ -0,0 +1,104 @@
+<#
+.SYNOPSIS
+ Opens inbound TCP ports in Windows Defender Firewall for CmTestSetup.md sections.
+
+.PARAMETER Section
+ One or more unique section IDs: DxaRuntime, Publishing, Optional, WebApp.
+
+.EXAMPLE
+ .\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime
+
+.EXAMPLE
+ .\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime,Publishing
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true, Position = 0)]
+ [ValidateSet('DxaRuntime', 'Publishing', 'Optional', 'WebApp')]
+ [string[]]$Section
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator)
+if (-not $isAdmin) {
+ throw "This script must be run in an elevated PowerShell session (Run as administrator)."
+}
+
+# Sections match CmTestSetup.md (same ports as Test-DxaRuntimePorts.ps1).
+$allSections = @(
+ @{
+ Id = 'DxaRuntime'
+ Name = 'Required for DXA runtime'
+ Ports = @(
+ @{ Port = 8082; Service = 'Discovery Service (/discovery.svc) and Token Service (/token.svc)' }
+ @{ Port = 8081; Service = 'Content Service (/content.svc, GraphQL)' }
+ @{ Port = 8083; Service = 'Session-enabled Content Service (XPM preview)' }
+ )
+ },
+ @{
+ Id = 'Publishing'
+ Name = 'Required for publishing (CM on the same box)'
+ Ports = @(
+ @{ Port = 80; Service = 'IIS (CME, Topology Manager if bound here, Core Service)' }
+ @{ Port = 443; Service = 'IIS HTTPS (CME, Topology Manager, Core Service)' }
+ @{ Port = 81; Service = 'Topology Manager (common alternate IIS binding)' }
+ @{ Port = 8084; Service = 'Content Deployer' }
+ )
+ },
+ @{
+ Id = 'Optional'
+ Name = 'Optional (open only if you use the feature)'
+ Ports = @(
+ @{ Port = 8087; Service = 'Context Engine' }
+ @{ Port = 8097; Service = 'IQ Query' }
+ @{ Port = 9200; Service = 'Elasticsearch / OpenSearch' }
+ @{ Port = 3389; Service = 'RDP' }
+ @{ Port = 1433; Service = 'SQL Server' }
+ )
+ },
+ @{
+ Id = 'WebApp'
+ Name = 'DXA web app listen port (local, not AWS)'
+ Ports = @(
+ @{ Port = 8080; Service = 'DXA Example WebApp (appsettings URLs)' }
+ )
+ }
+)
+
+Import-Module NetSecurity
+
+$selected = $allSections | Where-Object { $Section -contains $_.Id }
+$created = 0
+$skipped = 0
+
+foreach ($sec in $selected) {
+ Write-Host ("{0} - {1}" -f $sec.Id, $sec.Name) -ForegroundColor Yellow
+ foreach ($entry in $sec.Ports) {
+ $displayName = "DXA CmTest - $($sec.Id) - TCP $($entry.Port)"
+ $existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue
+ if ($existing) {
+ Write-Host (" EXISTS {0,-5} {1}" -f $entry.Port, $entry.Service) -ForegroundColor DarkGray
+ $skipped++
+ continue
+ }
+
+ New-NetFirewallRule `
+ -DisplayName $displayName `
+ -Description $entry.Service `
+ -Direction Inbound `
+ -Action Allow `
+ -Protocol TCP `
+ -LocalPort $entry.Port `
+ -Profile Any | Out-Null
+
+ Write-Host (" OPENED {0,-5} {1}" -f $entry.Port, $entry.Service) -ForegroundColor Green
+ $created++
+ }
+ Write-Host ""
+}
+
+Write-Host ("Created {0} inbound rule(s); {1} already present." -f $created, $skipped) -ForegroundColor Cyan
+Write-Host 'This only updates Windows Defender Firewall on this machine. Also open the same ports on the AWS security group.'
diff --git a/test/Start-CmTestWebApp.ps1 b/test/Start-CmTestWebApp.ps1
new file mode 100644
index 0000000..17c2dc6
--- /dev/null
+++ b/test/Start-CmTestWebApp.ps1
@@ -0,0 +1,1042 @@
+<#
+.SYNOPSIS
+ Starts the CM test DXA Docker image and checks that the site is up and can talk to CD.
+
+.PARAMETER Framework
+ Image TFM: net8 (dxa-example-webapp:net8-cmtest) or net10 (dxa-example-webapp:net10-cmtest).
+
+.PARAMETER CmServerIp
+ CM/CD IP used for Docker --add-host entries. Default: parsed from the image Dxa__Services__Discovery ENV.
+
+.PARAMETER WebsiteUrl
+ Topology website Base URL used for Host/Origin on page requests. Default http://dxa.tridiondemo.com (port 80). Not the CD host dxd.tridiondemo.com and not Docker port 8080.
+
+.PARAMETER HostPort
+ Host port published to container 8080. Default is the Topology website port (80 for http://dxa.tridiondemo.com). Must match Topology or the browser will fail localization.
+
+.PARAMETER StartupTimeoutSec
+ Seconds to wait for /system/health. Default 90.
+
+.PARAMETER RemoveWhenDone
+ Also remove the container after a successful run. On failure the container is always removed.
+
+.EXAMPLE
+ .\test\Start-CmTestWebApp.ps1 -Framework net8
+
+.EXAMPLE
+ .\test\Start-CmTestWebApp.ps1 -Framework net10 -WebsiteUrl http://dxa.tridiondemo.com
+#>
+[CmdletBinding()]
+param(
+ [ValidateSet('net8', 'net10')]
+ [string]$Framework,
+
+ [string]$CmServerIp,
+
+ [string]$WebsiteUrl = 'http://dxa.tridiondemo.com',
+
+ [int]$HostPort,
+
+ [int]$StartupTimeoutSec = 90,
+
+ [switch]$RemoveWhenDone
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$frameworkValues = @('net8', 'net10')
+if ([string]::IsNullOrWhiteSpace($Framework)) {
+ Write-Host ("Available Framework values: {0}" -f ($frameworkValues -join ', ')) -ForegroundColor Cyan
+ $Framework = Read-Host 'Framework'
+}
+if ($frameworkValues -notcontains $Framework) {
+ throw ("Framework must be one of: {0}. Got: '{1}'" -f ($frameworkValues -join ', '), $Framework)
+}
+
+if ($Framework -eq 'net8') {
+ $imageTag = 'dxa-example-webapp:net8-cmtest'
+}
+else {
+ $imageTag = 'dxa-example-webapp:net10-cmtest'
+}
+
+$containerName = 'dxa-cmtest'
+
+$websiteUri = $null
+$browseUrl = $null
+if (-not [string]::IsNullOrWhiteSpace($WebsiteUrl)) {
+ try {
+ $websiteUri = [Uri]$WebsiteUrl
+ }
+ catch {
+ throw "WebsiteUrl is not a valid URL: '$WebsiteUrl'"
+ }
+ if (-not $websiteUri.IsAbsoluteUri) {
+ throw "WebsiteUrl must be an absolute URL. Got: '$WebsiteUrl'"
+ }
+}
+
+if (-not $PSBoundParameters.ContainsKey('HostPort')) {
+ if ($null -ne $websiteUri -and $websiteUri.Port -gt 0) {
+ $HostPort = $websiteUri.Port
+ }
+ else {
+ $HostPort = 80
+ }
+}
+
+$baseUrl = "http://127.0.0.1:$HostPort"
+$browseUrl = $baseUrl
+if ($null -ne $websiteUri) {
+ $browseBuilder = New-Object System.UriBuilder($websiteUri)
+ $browseBuilder.Path = '/'
+ $browseBuilder.Query = ''
+ $browseUrl = $browseBuilder.Uri.AbsoluteUri
+}
+
+function Get-ImageDiscoveryUri {
+ param([string]$ImageTag)
+
+ $inspectJson = & docker inspect $ImageTag
+ if ($LASTEXITCODE -ne 0) {
+ return $null
+ }
+ $inspect = $inspectJson | ConvertFrom-Json
+ foreach ($entry in @($inspect[0].Config.Env)) {
+ if ($entry -like 'Dxa__Services__Discovery=*') {
+ $raw = $entry.Substring('Dxa__Services__Discovery='.Length)
+ try {
+ return [Uri]$raw
+ }
+ catch {
+ return $null
+ }
+ }
+ }
+ return $null
+}
+
+function Get-DiscoveryCapabilityInfo {
+ param(
+ [string]$DiscoveryBaseUrl,
+ [string]$TokenUrl,
+ [string]$ClientId = 'cduser',
+ [string]$ClientSecret = 'CDUserP@ssw0rd'
+ )
+
+ $info = @{
+ Hosts = @()
+ Token = $null
+ ContentServiceUrl = $null
+ }
+
+ $capabilityHostSet = New-Object System.Collections.Generic.HashSet[string]
+ try {
+ $tokenResponse = Invoke-WebRequest -Uri $TokenUrl -Method POST -UseBasicParsing -TimeoutSec 15 `
+ -ContentType 'application/x-www-form-urlencoded' `
+ -Body "grant_type=client_credentials&client_id=$ClientId&client_secret=$ClientSecret"
+ $tokenJson = $tokenResponse.Content | ConvertFrom-Json
+ $tokenProp = $tokenJson.PSObject.Properties | Where-Object { $_.Name -eq 'access_token' -or $_.Name -eq 'accessToken' } | Select-Object -First 1
+ if ($tokenProp) {
+ $info.Token = [string]$tokenProp.Value
+ }
+ }
+ catch {
+ Write-Host 'Could not read Token Service; Docker extra hosts may be incomplete.' -ForegroundColor DarkYellow
+ }
+
+ $headers = @{
+ Accept = 'application/json;odata.metadata=minimal'
+ 'OData-Version' = '4.0'
+ 'OData-MaxVersion' = '4.0'
+ }
+ if ($info.Token) {
+ $headers['Authorization'] = "Bearer $($info.Token)"
+ }
+
+ $paths = @(
+ 'TokenServiceCapabilities',
+ 'ContentServiceCapabilities',
+ 'PreviewWebServiceCapabilities',
+ 'DiscoveryServiceCapabilities',
+ 'DeployerCapabilities',
+ 'IQQueryCapabilities',
+ 'ContextServiceCapabilities'
+ )
+
+ foreach ($path in $paths) {
+ $url = "$DiscoveryBaseUrl/$path" + '?$top=1'
+ try {
+ $response = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -TimeoutSec 15
+ $json = $response.Content | ConvertFrom-Json
+ $items = @($json.value)
+ if ($items.Count -eq 0) {
+ continue
+ }
+ $item = $items[0]
+ $uriText = $null
+ foreach ($name in @('Uri', 'uri', 'URL', 'url')) {
+ if ($item.PSObject.Properties.Name -contains $name) {
+ $uriText = [string]$item.$name
+ break
+ }
+ }
+ if ($uriText) {
+ $uri = [Uri]$uriText
+ [void]$capabilityHostSet.Add($uri.Host)
+ if ($path -eq 'ContentServiceCapabilities') {
+ $info.ContentServiceUrl = $uriText
+ }
+ }
+ }
+ catch {
+ }
+ }
+
+ $info.Hosts = @($capabilityHostSet)
+ return $info
+}
+
+function Get-PublicationMapping {
+ param(
+ [string]$ContentServiceUrl,
+ [string]$CmServerIp,
+ [string]$Token,
+ [string[]]$CandidateHosts
+ )
+
+ if (-not $ContentServiceUrl -or -not $Token) {
+ return $null
+ }
+
+ try {
+ $contentUri = [Uri]$ContentServiceUrl
+ }
+ catch {
+ return $null
+ }
+
+ $builder = New-Object System.UriBuilder($contentUri)
+ $builder.Host = $CmServerIp
+ $graphQlUrl = $builder.Uri.AbsoluteUri.Replace('content.svc', 'cd/api')
+
+ $siteUrls = New-Object System.Collections.Generic.List[string]
+ foreach ($hostName in $CandidateHosts) {
+ if ([string]::IsNullOrWhiteSpace($hostName)) {
+ continue
+ }
+ [void]$siteUrls.Add("http://${hostName}/")
+ [void]$siteUrls.Add("https://${hostName}/")
+ [void]$siteUrls.Add("http://${hostName}:80/")
+ [void]$siteUrls.Add("https://${hostName}:443/")
+ }
+
+ $headers = @{
+ Authorization = "Bearer $Token"
+ Accept = 'application/json'
+ }
+
+ foreach ($siteUrl in $siteUrls) {
+ $payload = @{
+ query = 'query($namespaceId: Int!, $siteUrl: String!) { publicationMapping(namespaceId: $namespaceId, siteUrl: $siteUrl) { publicationId protocol domain port path } }'
+ variables = @{
+ namespaceId = 1
+ siteUrl = $siteUrl
+ }
+ }
+ $jsonBody = $payload | ConvertTo-Json -Compress -Depth 6
+ try {
+ $response = Invoke-WebRequest -Uri $graphQlUrl -Method POST -Headers $headers `
+ -ContentType 'application/json; charset=utf-8' -Body $jsonBody -UseBasicParsing -TimeoutSec 20
+ $data = $response.Content | ConvertFrom-Json
+ $mapping = $null
+ if ($data.PSObject.Properties.Name -contains 'data' -and $data.data -and $data.data.publicationMapping) {
+ $mapping = $data.data.publicationMapping
+ }
+ if ($mapping -and $mapping.domain) {
+ Write-Host ("Topology publication mapping: {0}://{1}:{2}{3} (probed {4})" -f $mapping.protocol, $mapping.domain, $mapping.port, $mapping.path, $siteUrl) -ForegroundColor Cyan
+ return $mapping
+ }
+ }
+ catch {
+ }
+ }
+
+ Write-Host 'Could not read a Topology publication mapping from Content Service GraphQL.' -ForegroundColor DarkYellow
+ return $null
+}
+
+function Get-HeadersFromWebsiteUrl {
+ param([Uri]$WebsiteUri)
+
+ $hostHeader = $WebsiteUri.Host
+ $defaultPort = 80
+ if ($WebsiteUri.Scheme -eq 'https') {
+ $defaultPort = 443
+ }
+ if ($WebsiteUri.IsDefaultPort -eq $false -and $WebsiteUri.Port -gt 0 -and $WebsiteUri.Port -ne $defaultPort) {
+ $hostHeader = '{0}:{1}' -f $WebsiteUri.Host, $WebsiteUri.Port
+ }
+ return @{
+ HostHeader = $hostHeader
+ OriginHeader = $WebsiteUri.GetLeftPart([System.UriPartial]::Authority)
+ }
+}
+
+function Test-PageLocalizationOk {
+ param($HttpResult)
+
+ if ($null -eq $HttpResult) {
+ return $false
+ }
+ $searchTied = Test-IsSearchModuleError -Html $HttpResult.Content
+ $statusOk = $HttpResult.StatusCode -eq 200 -or ($searchTied -and $HttpResult.StatusCode -ge 400)
+ $looksHtml = $statusOk -and (
+ $HttpResult.MediaType -match 'html' -or $HttpResult.Content -match '(?i)]*>(.*?)')
+ foreach ($pre in $preMatches) {
+ $text = $pre.Groups[1].Value
+ $text = [System.Net.WebUtility]::HtmlDecode($text)
+ $text = ($text -replace '\s+', ' ').Trim()
+ if (-not [string]::IsNullOrWhiteSpace($text)) {
+ [void]$details.Add($text)
+ }
+ }
+ return $details
+}
+
+function Test-HasFatalSectionRenderError {
+ param(
+ [string]$Html,
+ [switch]$BareSectionErrorIsSearchBox
+ )
+
+ if (-not (Test-HtmlHasSectionRenderError -Html $Html)) {
+ return $false
+ }
+ $details = Get-SectionRenderErrorDetails -Html $Html
+ if (Test-IsSearchModuleError -Html $Html) {
+ if ($null -eq $details -or $details.Count -eq 0) {
+ return $false
+ }
+ foreach ($detail in $details) {
+ $tiedToSearchBox = $detail -match '(?i)Search(?::|:)Entity(?::|:)SearchBox' -or $detail -match '(?i)SearchBox' -or $detail -match "(?i)View in the 'Search' area"
+ if (-not $tiedToSearchBox) {
+ return $true
+ }
+ }
+ return $false
+ }
+ if ($BareSectionErrorIsSearchBox -and ($null -eq $details -or $details.Count -eq 0)) {
+ return $false
+ }
+ return $true
+}
+
+function Test-ContainerSearchBoxErrorsOnly {
+ param([string]$ContainerName)
+
+ if ([string]::IsNullOrWhiteSpace($ContainerName)) {
+ return $false
+ }
+ $raw = ''
+ try {
+ $raw = (& docker logs --tail 200 $ContainerName 2>&1 | Out-String)
+ }
+ catch {
+ return $false
+ }
+ if ($raw -notmatch 'Search:Entity:SearchBox') {
+ return $false
+ }
+ $errorLines = [regex]::Matches($raw, '(?m)^.*\|Error\|.*$')
+ foreach ($match in $errorLines) {
+ $line = $match.Value
+ if ($line -notmatch 'Search:Entity:SearchBox' -and $line -notmatch "(?i)Search' area") {
+ return $false
+ }
+ }
+ return $true
+}
+
+function Write-SectionRenderErrorLog {
+ param(
+ [string]$PageUrl,
+ [string]$Html,
+ [string]$ContainerName
+ )
+
+ Write-Host (" Section render error on {0}" -f $PageUrl) -ForegroundColor Red
+ $details = Get-SectionRenderErrorDetails -Html $Html
+ if ($null -ne $details -and $details.Count -gt 0) {
+ Write-Host ' Page error details:' -ForegroundColor DarkYellow
+ foreach ($detail in $details) {
+ Write-Host (" {0}" -f $detail) -ForegroundColor DarkYellow
+ }
+ }
+ else {
+ Write-Host ' No ExceptionEntity details in HTML (shown only when the site runs in Development).' -ForegroundColor DarkYellow
+ }
+ if (-not [string]::IsNullOrWhiteSpace($ContainerName)) {
+ Write-Host ' Container error logs:' -ForegroundColor DarkYellow
+ try {
+ & docker logs --tail 80 $ContainerName 2>&1 | Where-Object { $_ -match '(?i)(\|Error\||\|Warn\||Exception)' }
+ }
+ catch {
+ }
+ }
+}
+
+function Get-HrefUrlsFromHtml {
+ param([string]$Html)
+
+ $urls = New-Object System.Collections.Generic.List[string]
+ if ([string]::IsNullOrWhiteSpace($Html)) {
+ return @()
+ }
+ $hrefMatches = [regex]::Matches($Html, 'href\s*=\s*["'']([^"'']+)["'']', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
+ foreach ($match in $hrefMatches) {
+ $href = $match.Groups[1].Value.Trim()
+ if (-not [string]::IsNullOrWhiteSpace($href)) {
+ [void]$urls.Add($href)
+ }
+ }
+ return @($urls)
+}
+
+function Get-LocUrlsFromSitemapXml {
+ param([string]$XmlText)
+
+ $urls = New-Object System.Collections.Generic.List[string]
+ if ([string]::IsNullOrWhiteSpace($XmlText) -or $XmlText -notmatch '$null | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Docker is not available. Start Docker Desktop / the Docker engine and retry.'
+ }
+}
+
+function Ensure-LocalHostsEntry {
+ param(
+ [string]$HostName,
+ [string]$IpAddress = '127.0.0.1'
+ )
+
+ if ([string]::IsNullOrWhiteSpace($HostName)) {
+ return
+ }
+ $parsed = $null
+ if ([System.Net.IPAddress]::TryParse($HostName, [ref]$parsed)) {
+ return
+ }
+
+ $hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
+ if (-not (Test-Path -LiteralPath $hostsPath)) {
+ Write-Host ("Hosts file not found: {0}" -f $hostsPath) -ForegroundColor DarkYellow
+ return
+ }
+
+ $lines = Get-Content -LiteralPath $hostsPath
+ foreach ($line in $lines) {
+ $trim = $line.Trim()
+ if ([string]::IsNullOrWhiteSpace($trim) -or $trim.StartsWith('#')) {
+ continue
+ }
+ $commentIndex = $trim.IndexOf('#')
+ if ($commentIndex -ge 0) {
+ $trim = $trim.Substring(0, $commentIndex).Trim()
+ }
+ $tokens = $trim -split '\s+'
+ if ($tokens.Count -lt 2) {
+ continue
+ }
+ if ($tokens[0] -ne $IpAddress) {
+ continue
+ }
+ for ($i = 1; $i -lt $tokens.Count; $i++) {
+ if ($tokens[$i] -eq $HostName) {
+ Write-Host ("Hosts file already has {0} {1}" -f $IpAddress, $HostName) -ForegroundColor DarkGray
+ return
+ }
+ }
+ }
+
+ $entry = "{0} {1}" -f $IpAddress, $HostName
+ try {
+ Add-Content -LiteralPath $hostsPath -Value $entry -Encoding ASCII
+ Write-Host ("Added to hosts file: {0}" -f $entry) -ForegroundColor Green
+ }
+ catch {
+ Write-Host ("Could not add '{0}' to {1}. Run this script as Administrator. {2}" -f $entry, $hostsPath, $_.Exception.Message) -ForegroundColor DarkYellow
+ }
+}
+
+function Get-HttpResult {
+ param(
+ [string]$Url,
+ [int]$TimeoutSec = 45,
+ [string]$HostHeader,
+ [string]$OriginHeader
+ )
+
+ $result = @{
+ Url = $Url
+ StatusCode = 0
+ Content = ''
+ MediaType = ''
+ Error = $null
+ HostHeader = $HostHeader
+ OriginHeader = $OriginHeader
+ }
+
+ try {
+ if ($HostHeader -or $OriginHeader) {
+ $request = [System.Net.HttpWebRequest]::Create($Url)
+ $request.Method = 'GET'
+ $request.Timeout = [Math]::Max(1000, $TimeoutSec * 1000)
+ $request.AllowAutoRedirect = $true
+ if ($HostHeader) {
+ $request.Host = $HostHeader
+ }
+ if ($OriginHeader) {
+ $request.Headers['Origin'] = $OriginHeader
+ }
+ $response = $request.GetResponse()
+ $result.StatusCode = [int]$response.StatusCode
+ $result.MediaType = [string]$response.ContentType
+ $stream = $response.GetResponseStream()
+ if ($stream) {
+ $reader = New-Object System.IO.StreamReader($stream)
+ $result.Content = $reader.ReadToEnd()
+ $reader.Close()
+ }
+ $response.Close()
+ }
+ else {
+ $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $TimeoutSec -MaximumRedirection 5
+ $result.StatusCode = [int]$response.StatusCode
+ $result.Content = [string]$response.Content
+ if ($response.Headers['Content-Type']) {
+ $result.MediaType = [string]$response.Headers['Content-Type']
+ }
+ }
+ }
+ catch {
+ $result.Error = $_.Exception.Message
+ $errResponse = $null
+ $ex = $_.Exception
+ while ($null -ne $ex) {
+ if ($ex -is [System.Net.WebException]) {
+ $errResponse = $ex.Response
+ break
+ }
+ $ex = $ex.InnerException
+ }
+ if ($null -ne $errResponse) {
+ try {
+ $result.StatusCode = [int]$errResponse.StatusCode
+ }
+ catch {
+ }
+ try {
+ $stream = $errResponse.GetResponseStream()
+ if ($null -ne $stream) {
+ $reader = New-Object System.IO.StreamReader($stream)
+ $result.Content = $reader.ReadToEnd()
+ $reader.Close()
+ }
+ }
+ catch {
+ }
+ }
+ }
+
+ return $result
+}
+
+function Write-TestResult {
+ param(
+ [bool]$Passed,
+ [string]$Name,
+ [string]$Detail
+ )
+
+ if ($Passed) {
+ Write-Host (" PASS {0} {1}" -f $Name, $Detail) -ForegroundColor Green
+ }
+ else {
+ Write-Host (" FAIL {0} {1}" -f $Name, $Detail) -ForegroundColor Red
+ }
+}
+
+Test-DockerAvailable
+
+if ($null -ne $websiteUri) {
+ Ensure-LocalHostsEntry -HostName $websiteUri.Host -IpAddress '127.0.0.1'
+}
+
+& docker image inspect $imageTag 2>$null | Out-Null
+if ($LASTEXITCODE -ne 0) {
+ throw "Image '$imageTag' was not found. Run .\test\New-CmTestDockerfile.ps1 -Framework $Framework -CmServerIp first."
+}
+
+$discoveryUri = Get-ImageDiscoveryUri -ImageTag $imageTag
+if (-not $CmServerIp) {
+ if ($discoveryUri -and $discoveryUri.Host) {
+ $parsed = $null
+ if ([System.Net.IPAddress]::TryParse($discoveryUri.Host, [ref]$parsed)) {
+ $CmServerIp = $discoveryUri.Host
+ }
+ }
+}
+if (-not $CmServerIp) {
+ throw 'Provide -CmServerIp (or rebuild the image with New-CmTestDockerfile.ps1 so Dxa__Services__Discovery contains the CM IP).'
+}
+
+$discoveryPort = 8082
+if ($discoveryUri -and $discoveryUri.Port -gt 0) {
+ $discoveryPort = $discoveryUri.Port
+}
+$discoveryBase = "http://${CmServerIp}:${discoveryPort}/discovery.svc"
+$tokenUrl = "http://${CmServerIp}:${discoveryPort}/token.svc"
+
+Write-Host "Discovery: $discoveryBase" -ForegroundColor DarkGray
+$discoveryInfo = Get-DiscoveryCapabilityInfo -DiscoveryBaseUrl $discoveryBase -TokenUrl $tokenUrl
+$capabilityHosts = @($discoveryInfo.Hosts)
+$dockerHostNames = New-Object System.Collections.Generic.HashSet[string]
+$addHostArgs = @()
+foreach ($capabilityHost in $capabilityHosts) {
+ $parsedHost = $null
+ $isIp = [System.Net.IPAddress]::TryParse($capabilityHost, [ref]$parsedHost)
+ if ($isIp -or $capabilityHost -eq 'localhost' -or $capabilityHost -eq '127.0.0.1') {
+ continue
+ }
+ if ($dockerHostNames.Add($capabilityHost)) {
+ Write-Host ("Mapping Discovery host {0} -> {1} (docker --add-host)" -f $capabilityHost, $CmServerIp) -ForegroundColor Cyan
+ $addHostArgs += '--add-host'
+ $addHostArgs += "${capabilityHost}:$CmServerIp"
+ }
+}
+if ($null -ne $websiteUri -and $dockerHostNames.Add($websiteUri.Host)) {
+ Write-Host ("Mapping Topology website host {0} -> {1} (docker --add-host)" -f $websiteUri.Host, $CmServerIp) -ForegroundColor Cyan
+ $addHostArgs += '--add-host'
+ $addHostArgs += "$($websiteUri.Host):$CmServerIp"
+}
+if ($addHostArgs.Count -eq 0) {
+ Write-Host 'No extra Docker host mappings (Discovery URLs already use an IP or localhost).' -ForegroundColor DarkGray
+}
+
+$topologyHeaders = @{
+ HostHeader = $null
+ OriginHeader = $null
+}
+if ($null -ne $websiteUri) {
+ $topologyHeaders = Get-HeadersFromWebsiteUrl -WebsiteUri $websiteUri
+ Write-Host ("Topology website {0} -> Host '{1}', Origin '{2}'" -f $WebsiteUrl, $topologyHeaders.HostHeader, $topologyHeaders.OriginHeader) -ForegroundColor Cyan
+}
+
+$existing = & docker ps -aq --filter "name=^/${containerName}$"
+if (-not $existing) {
+ $existing = & docker ps -aq --filter "name=$containerName"
+}
+if ($existing) {
+ Write-Host "Removing existing container $containerName..." -ForegroundColor DarkGray
+ & docker rm -f $containerName | Out-Null
+}
+
+Write-Host ("Starting {0} as {1} (host port {2} -> container 8080)..." -f $imageTag, $containerName, $HostPort) -ForegroundColor Cyan
+$containerId = & docker run -d --name $containerName -p "${HostPort}:8080" `
+ -e Dxa__PreferOriginHeaderForLocalizationResolver=true `
+ -e ASPNETCORE_ENVIRONMENT=Development `
+ @addHostArgs $imageTag
+if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) {
+ throw ("docker run failed for {0}. If host port {1} is in use, stop the process bound to it (often IIS on 80) or pass -HostPort. Topology localization requires the browser port to match the website Base URL (default 80, not 8080)." -f $imageTag, $HostPort)
+}
+
+$failed = 0
+$runError = $null
+try {
+ Write-Host "Waiting for the site at $baseUrl ..." -ForegroundColor Cyan
+ $deadline = [DateTime]::UtcNow.AddSeconds($StartupTimeoutSec)
+ $health = $null
+ while ([DateTime]::UtcNow -lt $deadline) {
+ $health = Get-HttpResult -Url "$baseUrl/system/health" -TimeoutSec 5
+ if ($health.StatusCode -eq 200 -and $health.Content -match 'DXA Health Check OK') {
+ break
+ }
+ Start-Sleep -Seconds 2
+ }
+
+ Write-Host 'Connectivity tests' -ForegroundColor Yellow
+
+ $siteUp = $health.StatusCode -eq 200 -and $health.Content -match 'DXA Health Check OK'
+ if ($siteUp) {
+ Write-TestResult -Passed $true -Name 'Site running' -Detail '/system/health returned 200 DXA Health Check OK'
+ }
+ else {
+ $failed++
+ $detail = if ($health.Error) { $health.Error } else { "HTTP $($health.StatusCode)" }
+ Write-TestResult -Passed $false -Name 'Site running' -Detail "/system/health: $detail"
+ }
+
+ $homePage = Get-HttpResult -Url "$baseUrl/" -TimeoutSec 45 -HostHeader $topologyHeaders.HostHeader -OriginHeader $topologyHeaders.OriginHeader
+ if (-not (Test-PageLocalizationOk -HttpResult $homePage) -and [string]::IsNullOrWhiteSpace($WebsiteUrl)) {
+ Write-Host 'Home page missed Topology website URL; trying GraphQL mapping then Origin probes (not port 8080)...' -ForegroundColor DarkYellow
+ $publicationMapping = Get-PublicationMapping -ContentServiceUrl $discoveryInfo.ContentServiceUrl -CmServerIp $CmServerIp -Token $discoveryInfo.Token -CandidateHosts $capabilityHosts
+ $fallbackHeaders = Get-TopologyRequestHeaders -Mapping $publicationMapping -FallbackHost $null
+ $originProbes = New-Object System.Collections.Generic.List[object]
+ [void]$originProbes.Add($fallbackHeaders)
+ if ($null -ne $websiteUri) {
+ $probeHost = $websiteUri.Host
+ [void]$originProbes.Add(@{ HostHeader = $probeHost; OriginHeader = "https://${probeHost}" })
+ [void]$originProbes.Add(@{ HostHeader = $probeHost; OriginHeader = "http://${probeHost}" })
+ [void]$originProbes.Add(@{ HostHeader = "${probeHost}:443"; OriginHeader = "https://${probeHost}:443" })
+ [void]$originProbes.Add(@{ HostHeader = $probeHost; OriginHeader = "http://${probeHost}:80" })
+ [void]$originProbes.Add(@{ HostHeader = "${probeHost}:81"; OriginHeader = "http://${probeHost}:81" })
+ }
+ foreach ($probe in $originProbes) {
+ if ([string]::IsNullOrWhiteSpace($probe.HostHeader) -and [string]::IsNullOrWhiteSpace($probe.OriginHeader)) {
+ continue
+ }
+ if ($probe.OriginHeader -eq $topologyHeaders.OriginHeader -and $probe.HostHeader -eq $topologyHeaders.HostHeader) {
+ continue
+ }
+ $homePage = Get-HttpResult -Url "$baseUrl/" -TimeoutSec 45 -HostHeader $probe.HostHeader -OriginHeader $probe.OriginHeader
+ if (Test-HomePageSuccess -HttpResult $homePage) {
+ $topologyHeaders = $probe
+ Write-Host ("Home page succeeded with Origin '{0}' Host '{1}'" -f $probe.OriginHeader, $probe.HostHeader) -ForegroundColor Cyan
+ break
+ }
+ }
+ }
+ if (Test-HomePageSuccess -HttpResult $homePage) {
+ $hostNote = if ($topologyHeaders.OriginHeader) { " Origin=$($topologyHeaders.OriginHeader)" } elseif ($topologyHeaders.HostHeader) { " Host=$($topologyHeaders.HostHeader)" } else { '' }
+ Write-TestResult -Passed $true -Name 'Home page' -Detail "HTTP $($homePage.StatusCode) HTML from /$hostNote"
+ }
+ elseif (Test-PageLocalizationOk -HttpResult $homePage -and (Test-IsSearchModuleError -Html $homePage.Content) -and -not (Test-HasFatalSectionRenderError -Html $homePage.Content)) {
+ $hostNote = if ($topologyHeaders.OriginHeader) { " Origin=$($topologyHeaders.OriginHeader)" } elseif ($topologyHeaders.HostHeader) { " Host=$($topologyHeaders.HostHeader)" } else { '' }
+ Write-TestResult -Passed $true -Name 'Home page' -Detail "HTTP $($homePage.StatusCode) HTML from /$hostNote (Search:Entity:SearchBox ignored)"
+ Write-Host ' WARN Home page Search Module errors are ignored.' -ForegroundColor DarkYellow
+ }
+ else {
+ $failed++
+ if (Test-PageLocalizationOk -HttpResult $homePage) {
+ Write-TestResult -Passed $false -Name 'Home page' -Detail 'Page opened but a region failed to render.'
+ Write-SectionRenderErrorLog -PageUrl "$baseUrl/" -Html $homePage.Content -ContainerName $containerName
+ }
+ else {
+ $snippet = ''
+ if ($homePage.Content) {
+ $collapsed = ($homePage.Content -replace '\s+', ' ')
+ $snippet = $collapsed.Substring(0, [Math]::Min(180, $collapsed.Length))
+ }
+ $detail = "HTTP $($homePage.StatusCode) $($homePage.Error) $snippet"
+ if ($homePage.Content -match 'No matching Localization') {
+ $detail += ' Topology website Base URL must match Origin/Host (default http://dxa.tridiondemo.com).'
+ }
+ Write-TestResult -Passed $false -Name 'Home page' -Detail $detail.Trim()
+ }
+ }
+
+ if (Test-PageLocalizationOk -HttpResult $homePage) {
+ $websiteHostName = $null
+ if ($null -ne $websiteUri) {
+ $websiteHostName = $websiteUri.Host
+ }
+ $sitemapFailed = Test-SitemapLinks -LocalBaseUrl $baseUrl -TopologyHeaders $topologyHeaders -WebsiteHost $websiteHostName -HomeHtml $homePage.Content -ContainerName $containerName
+ if ($sitemapFailed -gt 0) {
+ $failed++
+ }
+ }
+
+ $nav = Get-HttpResult -Url "$baseUrl/navigation.json" -TimeoutSec 45 -HostHeader $topologyHeaders.HostHeader -OriginHeader $topologyHeaders.OriginHeader
+ $navJson = $false
+ if ($nav.StatusCode -eq 200 -and $nav.Content) {
+ $trim = $nav.Content.TrimStart()
+ $navJson = $trim.StartsWith('{') -or $trim.StartsWith('[')
+ }
+ if ($navJson) {
+ Write-TestResult -Passed $true -Name 'CD communication' -Detail '/navigation.json returned JSON (Content Service / Discovery)'
+ }
+ else {
+ $failed++
+ $detail = if ($nav.Error) { $nav.Error } else { "HTTP $($nav.StatusCode) (expected JSON from CD)" }
+ Write-TestResult -Passed $false -Name 'CD communication' -Detail "/navigation.json: $detail"
+ }
+}
+catch {
+ $runError = $_.Exception.Message
+ Write-Host ("Start script error: {0}" -f $runError) -ForegroundColor Red
+}
+finally {
+ $cleanup = ($failed -gt 0) -or $RemoveWhenDone -or (-not [string]::IsNullOrWhiteSpace($runError))
+ if ($failed -gt 0 -or -not [string]::IsNullOrWhiteSpace($runError)) {
+ Write-Host ''
+ Write-Host 'Container logs (last 80 lines):' -ForegroundColor DarkYellow
+ & docker logs --tail 80 $containerName
+ }
+
+ if ($cleanup) {
+ Write-Host "Removing container $containerName..." -ForegroundColor DarkGray
+ & docker rm -f $containerName 2>$null | Out-Null
+ }
+ else {
+ Write-Host ("Container {0} is running. Browse {1}" -f $containerName, $browseUrl) -ForegroundColor Cyan
+ }
+}
+
+Write-Host ''
+if ($failed -eq 0 -and [string]::IsNullOrWhiteSpace($runError)) {
+ Write-Host 'Site is running and CD communication succeeded.' -ForegroundColor Green
+ if (-not $RemoveWhenDone) {
+ Write-Host ("Opening default browser: {0}" -f $browseUrl) -ForegroundColor Cyan
+ Start-Process $browseUrl
+ }
+ exit 0
+}
+
+if (-not [string]::IsNullOrWhiteSpace($runError)) {
+ Write-Host ("Start script failed: {0}" -f $runError) -ForegroundColor Red
+ exit 1
+}
+
+Write-Host ("{0} connectivity test(s) failed." -f $failed) -ForegroundColor Red
+exit 1
diff --git a/test/Test-DxaRuntimePorts.ps1 b/test/Test-DxaRuntimePorts.ps1
new file mode 100644
index 0000000..00e5061
--- /dev/null
+++ b/test/Test-DxaRuntimePorts.ps1
@@ -0,0 +1,394 @@
+<#
+.SYNOPSIS
+ Tests TCP ports from CmTestSetup.md against a CM/CD server IP, grouped by section.
+ When Discovery is reachable, lists capability URLs/ports and overrides default ports if they differ.
+
+.PARAMETER CmServerIp
+ IPv4 or IPv6 address of the combined CM/CD host (not a hostname).
+
+.PARAMETER TimeoutMs
+ Connect timeout per port in milliseconds. Default 4000.
+
+.PARAMETER DiscoveryPort
+ Default Discovery TCP/HTTP port before capabilities are read. Default 8082.
+
+.PARAMETER ClientId
+ OAuth client id for Token Service (appsettings Dxa:OAuth:ClientId). Default cduser.
+
+.PARAMETER ClientSecret
+ OAuth client secret for Token Service. Default matches Example WebApp appsettings.
+
+.PARAMETER DxaRuntimeOnly
+ Test only the "Required for DXA runtime" section.
+
+.PARAMETER PassThru
+ Return $true/$false instead of calling exit. Use when invoked from another script.
+
+.EXAMPLE
+ .\test\Test-DxaRuntimePorts.ps1 -CmServerIp 192.0.2.10
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true, Position = 0)]
+ [string]$CmServerIp,
+
+ [int]$TimeoutMs = 4000,
+
+ [int]$DiscoveryPort = 8082,
+
+ [string]$ClientId = 'cduser',
+
+ [string]$ClientSecret = 'CDUserP@ssw0rd',
+
+ [switch]$DxaRuntimeOnly,
+
+ [switch]$PassThru
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$parsedIp = $null
+if (-not [System.Net.IPAddress]::TryParse($CmServerIp, [ref]$parsedIp)) {
+ throw "CmServerIp must be an IP address. Got: '$CmServerIp'"
+}
+
+# Sections match CmTestSetup.md. Required sections affect the exit code; Optional does not.
+# Capability names match Discovery OData entity sets (used to override default ports).
+$sections = @(
+ @{
+ Name = 'Required for DXA runtime'
+ Required = $true
+ Ports = @(
+ @{ Port = $DiscoveryPort; Service = 'Discovery Service (/discovery.svc)'; Capability = 'DiscoveryService' }
+ @{ Port = $DiscoveryPort; Service = 'Token Service (/token.svc)'; Capability = 'TokenService' }
+ @{ Port = 8081; Service = 'Content Service (/content.svc, GraphQL)'; Capability = 'ContentService' }
+ @{ Port = 8083; Service = 'Session-enabled Content Service (XPM preview)'; Capability = 'PreviewWebService' }
+ )
+ },
+ @{
+ Name = 'Required for publishing (CM on the same box)'
+ Required = $true
+ Ports = @(
+ @{ Port = 80; Service = 'IIS (CME, Topology Manager if bound here, Core Service)' }
+ @{ Port = 443; Service = 'IIS HTTPS (CME, Topology Manager, Core Service)' }
+ @{ Port = 81; Service = 'Topology Manager (common alternate IIS binding)' }
+ @{ Port = 8084; Service = 'Content Deployer'; Capability = 'Deployer' }
+ )
+ },
+ @{
+ Name = 'Optional (open only if you use the feature)'
+ Required = $false
+ Ports = @(
+ @{ Port = 8087; Service = 'Context Engine'; Capability = 'ContextService' }
+ @{ Port = 8097; Service = 'IQ Query'; Capability = 'IQQuery' }
+ @{ Port = 9200; Service = 'Elasticsearch / OpenSearch' }
+ @{ Port = 3389; Service = 'RDP' }
+ @{ Port = 1433; Service = 'SQL Server' }
+ )
+ }
+)
+
+$capabilityQueries = @(
+ @{ Capability = 'DiscoveryService'; Paths = @('DiscoveryServiceCapabilities') }
+ @{ Capability = 'TokenService'; Paths = @('TokenServiceCapabilities') }
+ @{ Capability = 'ContentService'; Paths = @('ContentServiceCapabilities') }
+ @{ Capability = 'PreviewWebService'; Paths = @('PreviewWebServiceCapabilities', 'SessionEnabledContentServiceCapabilities') }
+ @{ Capability = 'Deployer'; Paths = @('DeployerCapabilities', 'DeployerCapability') }
+ @{ Capability = 'ContextService'; Paths = @('ContextServiceCapabilities') }
+ @{ Capability = 'IQQuery'; Paths = @('IQQueryCapabilities') }
+)
+
+function Test-TcpPortOpen {
+ param(
+ [System.Net.IPAddress]$Address,
+ [int]$Port,
+ [int]$TimeoutMs
+ )
+
+ $client = New-Object System.Net.Sockets.TcpClient
+ try {
+ $async = $client.BeginConnect($Address, $Port, $null, $null)
+ if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
+ return $false
+ }
+ $client.EndConnect($async)
+ return $client.Connected
+ }
+ catch {
+ return $false
+ }
+ finally {
+ $client.Close()
+ }
+}
+
+function Get-HttpStatusCode {
+ param($ErrorRecord)
+
+ $ex = $ErrorRecord.Exception
+ if ($ex.Response -and $ex.Response.StatusCode) {
+ return [int]$ex.Response.StatusCode
+ }
+ if ($ex.InnerException -and $ex.InnerException.Response) {
+ return [int]$ex.InnerException.Response.StatusCode
+ }
+ return 0
+}
+
+function Get-ODataUriProperty {
+ param($Item)
+
+ if ($null -eq $Item) {
+ return $null
+ }
+ foreach ($name in @('Uri', 'uri', 'URL', 'url')) {
+ if ($Item.PSObject.Properties.Name -contains $name) {
+ $text = [string]$Item.$name
+ if (-not [string]::IsNullOrWhiteSpace($text)) {
+ return $text
+ }
+ }
+ }
+ return $null
+}
+
+function Invoke-DiscoveryRequest {
+ param(
+ [string]$Url,
+ [string]$Method = 'GET',
+ [string]$Token,
+ [string]$Body
+ )
+
+ $headers = @{
+ Accept = 'application/json;odata.metadata=minimal'
+ 'OData-Version' = '4.0'
+ 'OData-MaxVersion' = '4.0'
+ }
+ if ($Token) {
+ $headers['Authorization'] = "Bearer $Token"
+ }
+
+ $params = @{
+ Uri = $Url
+ Method = $Method
+ Headers = $headers
+ TimeoutSec = [Math]::Max(1, [Math]::Ceiling($TimeoutMs / 1000.0))
+ UseBasicParsing = $true
+ ErrorAction = 'Stop'
+ }
+ if ($Method -eq 'POST') {
+ $params['ContentType'] = 'application/x-www-form-urlencoded'
+ $params['Body'] = $Body
+ }
+
+ return Invoke-WebRequest @params
+}
+
+function Get-DiscoveryCapabilities {
+ param(
+ [string]$CmServerIp,
+ [int]$DiscoveryPort,
+ [string]$ClientId,
+ [string]$ClientSecret
+ )
+
+ $result = @{
+ Connected = $false
+ Capabilities = @()
+ }
+
+ $discoveryBase = "http://${CmServerIp}:${DiscoveryPort}/discovery.svc"
+ $tokenUrl = "http://${CmServerIp}:${DiscoveryPort}/token.svc"
+
+ try {
+ $null = Invoke-DiscoveryRequest -Url $discoveryBase
+ $result.Connected = $true
+ }
+ catch {
+ $status = Get-HttpStatusCode $_
+ if ($status -ge 200) {
+ $result.Connected = $true
+ }
+ else {
+ Write-Host ("Discovery HTTP call failed: {0}" -f $_.Exception.Message) -ForegroundColor DarkYellow
+ return $result
+ }
+ }
+
+ $token = $null
+ try {
+ $tokenBody = "grant_type=client_credentials&client_id=$ClientId&client_secret=$ClientSecret"
+ $tokenResponse = Invoke-DiscoveryRequest -Url $tokenUrl -Method POST -Body $tokenBody
+ $tokenJson = $tokenResponse.Content | ConvertFrom-Json
+ $tokenProp = $tokenJson.PSObject.Properties | Where-Object { $_.Name -eq 'access_token' -or $_.Name -eq 'accessToken' } | Select-Object -First 1
+ if ($tokenProp) {
+ $token = [string]$tokenProp.Value
+ }
+ }
+ catch {
+ Write-Host 'Could not obtain an OAuth token from Token Service. Capability URLs may be unavailable.' -ForegroundColor DarkYellow
+ }
+
+ foreach ($query in $capabilityQueries) {
+ $uriText = $null
+ foreach ($path in $query.Paths) {
+ $url = "$discoveryBase/$path" + '?$top=1'
+ try {
+ $response = Invoke-DiscoveryRequest -Url $url -Token $token
+ $json = $response.Content | ConvertFrom-Json
+ $items = @($json.value)
+ if ($items.Count -gt 0) {
+ $uriText = Get-ODataUriProperty $items[0]
+ if ($uriText) {
+ break
+ }
+ }
+ }
+ catch {
+ $status = Get-HttpStatusCode $_
+ if ($status -eq 401 -or $status -eq 403) {
+ Write-Host ("Discovery returned {0} for {1}" -f $status, $path) -ForegroundColor DarkYellow
+ break
+ }
+ }
+ }
+
+ if (-not $uriText) {
+ continue
+ }
+
+ try {
+ $uri = [Uri]$uriText
+ }
+ catch {
+ continue
+ }
+
+ $port = $uri.Port
+ if ($port -lt 0) {
+ if ($uri.Scheme -eq 'https') { $port = 443 } else { $port = 80 }
+ }
+
+ $result.Capabilities += @{
+ Capability = $query.Capability
+ Url = $uriText
+ Port = $port
+ Host = $uri.Host
+ }
+ }
+
+ return $result
+}
+
+function Set-PortsFromDiscovery {
+ param($Sections, $Capabilities)
+
+ $byName = @{}
+ foreach ($cap in $Capabilities) {
+ $byName[$cap.Capability] = $cap
+ }
+
+ foreach ($section in $Sections) {
+ foreach ($entry in $section.Ports) {
+ if (-not $entry.ContainsKey('Capability')) {
+ continue
+ }
+ if (-not $byName.ContainsKey($entry.Capability)) {
+ continue
+ }
+ $cap = $byName[$entry.Capability]
+ $defaultPort = $entry.Port
+ if ($cap.Port -ne $defaultPort) {
+ Write-Host (" Override {0}: default TCP {1} -> {2} ({3})" -f $entry.Capability, $defaultPort, $cap.Port, $cap.Url) -ForegroundColor Cyan
+ $entry.Port = $cap.Port
+ }
+ $entry.Service = '{0} [{1}]' -f $entry.Service, $cap.Url
+ }
+ }
+}
+
+if ($DxaRuntimeOnly) {
+ $sections = @($sections | Where-Object { $_.Name -eq 'Required for DXA runtime' })
+}
+
+Write-Host "Testing CmTestSetup ports on $CmServerIp (timeout ${TimeoutMs}ms)" -ForegroundColor Cyan
+Write-Host "DXA web app listen port 8080 is local and is not tested against the CM server." -ForegroundColor DarkGray
+Write-Host ""
+
+$discoveryTcpOpen = Test-TcpPortOpen -Address $parsedIp -Port $DiscoveryPort -TimeoutMs $TimeoutMs
+if ($discoveryTcpOpen) {
+ Write-Host ("Discovery TCP {0} is open. Reading capability URLs..." -f $DiscoveryPort) -ForegroundColor Cyan
+ $discovered = Get-DiscoveryCapabilities -CmServerIp $CmServerIp -DiscoveryPort $DiscoveryPort -ClientId $ClientId -ClientSecret $ClientSecret
+ if ($discovered.Connected -and $discovered.Capabilities.Count -gt 0) {
+ Write-Host ""
+ Write-Host 'Discovery configured URLs and ports:' -ForegroundColor Yellow
+ foreach ($cap in $discovered.Capabilities) {
+ $hostNote = ''
+ if ($cap.Host -eq 'localhost' -or $cap.Host -eq '127.0.0.1') {
+ $hostNote = ' (localhost - DXA on another machine cannot use this host)'
+ }
+ Write-Host (" {0,-20} {1,-6} {2}{3}" -f $cap.Capability, $cap.Port, $cap.Url, $hostNote)
+ }
+ Write-Host ""
+ Write-Host 'Applying Discovery ports over CmTestSetup defaults where they differ...' -ForegroundColor Cyan
+ Set-PortsFromDiscovery -Sections $sections -Capabilities $discovered.Capabilities
+ Write-Host ""
+ }
+ elseif ($discovered.Connected) {
+ Write-Host 'Discovery responded but returned no capability URLs. Using CmTestSetup default ports.' -ForegroundColor DarkYellow
+ Write-Host ""
+ }
+ else {
+ Write-Host 'Discovery TCP is open but the HTTP service was not usable. Using CmTestSetup default ports.' -ForegroundColor DarkYellow
+ Write-Host ""
+ }
+}
+else {
+ Write-Host ("Discovery TCP {0} is closed. Using CmTestSetup default ports." -f $DiscoveryPort) -ForegroundColor DarkYellow
+ Write-Host ""
+}
+
+$requiredFailed = 0
+$optionalClosed = 0
+
+foreach ($section in $sections) {
+ Write-Host $section.Name -ForegroundColor Yellow
+ foreach ($entry in $section.Ports) {
+ $open = Test-TcpPortOpen -Address $parsedIp -Port $entry.Port -TimeoutMs $TimeoutMs
+ if ($open) {
+ Write-Host (" OPEN {0,-5} {1}" -f $entry.Port, $entry.Service) -ForegroundColor Green
+ }
+ else {
+ Write-Host (" CLOSED {0,-5} {1}" -f $entry.Port, $entry.Service) -ForegroundColor Red
+ if ($section.Required) {
+ $requiredFailed++
+ }
+ else {
+ $optionalClosed++
+ }
+ }
+ }
+ Write-Host ""
+}
+
+$success = $requiredFailed -eq 0
+if ($success) {
+ Write-Host 'All required ports are reachable from this machine.' -ForegroundColor Green
+ if ($optionalClosed -gt 0) {
+ Write-Host ("{0} optional port(s) are closed (expected if those features are unused)." -f $optionalClosed) -ForegroundColor DarkYellow
+ }
+}
+else {
+ Write-Host ("{0} required port(s) are not reachable. Check AWS security group DXA-CmTest and Windows Firewall." -f $requiredFailed) -ForegroundColor Red
+ if ($optionalClosed -gt 0) {
+ Write-Host ("{0} optional port(s) are also closed." -f $optionalClosed) -ForegroundColor DarkYellow
+ }
+}
+
+if ($PassThru) {
+ return $success
+}
+
+exit $(if ($success) { 0 } else { 1 })
From ed937ba897088c457336db6a87d60999e8dcf195 Mon Sep 17 00:00:00 2001
From: Indio Giles <33137578+rwsigiles@users.noreply.github.com>
Date: Mon, 24 Aug 2026 21:54:29 +0100
Subject: [PATCH 7/7] Documentation update
---
NuGetApiToken.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
README.md | 5 ++++-
2 files changed, 50 insertions(+), 1 deletion(-)
create mode 100644 NuGetApiToken.md
diff --git a/NuGetApiToken.md b/NuGetApiToken.md
new file mode 100644
index 0000000..ca00038
--- /dev/null
+++ b/NuGetApiToken.md
@@ -0,0 +1,46 @@
+# Create a NuGet.org API token
+
+Use a **nuget.org API key** when you push a stable DXA release to the public feed with `Release-Dxa.ps1`. Do not use the default Nexus key from `build.proj` against nuget.org.
+
+Official reference: [Create API keys](https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package#create-api-keys).
+
+## Prerequisites
+
+- A [nuget.org](https://www.nuget.org/) account that is allowed to publish `Tridion.Dxa.*` packages (organization membership as required by RWS).
+- Two-factor authentication enabled on that account (required by nuget.org).
+
+## Create the key
+
+1. Sign in at [https://www.nuget.org/](https://www.nuget.org/).
+2. Open **API Keys**: [https://www.nuget.org/account/apikeys](https://www.nuget.org/account/apikeys) (or select your username, then **API Keys**).
+3. Select **Create**.
+4. Fill in:
+ - **Key name** — for example `dxa-core-push-YYYY-MM`.
+ - **Expires** — pick a short lifetime (for example 1 year or less). Rotate before expiry.
+ - **Glob pattern** — `Tridion.Dxa.*` (limits the key to DXA package IDs).
+ - **Select scopes** — enable **Push** (new packages and new versions). Enable **Unlist** only if you must unlist a bad package.
+5. Select **Create**. Copy the key immediately. nuget.org shows the full value **once**.
+
+Store the key in a password manager or a CI secret. Never commit it to git, paste it into `build.proj`, or share it in chat.
+
+## Use the key
+
+Push a verified stable build to nuget.org:
+
+```powershell
+.\Release-Dxa.ps1 -NuGetSource https://api.nuget.org/v3/index.json -ApiKey
+```
+
+Replace `` with the value you copied. Preview releases (`-Preview`) stay on internal Nexus and must not use nuget.org.
+
+To store the key locally for `dotnet nuget` (optional):
+
+```powershell
+dotnet nuget setapikey --source https://api.nuget.org/v3/index.json
+```
+
+## If a key is leaked
+
+1. On [API Keys](https://www.nuget.org/account/apikeys), **Regenerate** or **Remove** the leaked key.
+2. Create a new key with the same glob and scopes.
+3. Update any CI secrets or local `setapikey` entries.
diff --git a/README.md b/README.md
index 096d948..d0fbf73 100644
--- a/README.md
+++ b/README.md
@@ -54,9 +54,12 @@ Each package step:
.\Release-Dxa.ps1 -Preview -NonInteractive
# After Nexus has been verified, re-publish a stable build to public NuGet.org.
+# Create the API key first: see NuGetApiToken.md
.\Release-Dxa.ps1 -NuGetSource https://api.nuget.org/v3/index.json -ApiKey
```
+Create a nuget.org API token before a public push: [NuGetApiToken.md](NuGetApiToken.md).
+
### Parameters
| Parameter | Default | Purpose |
@@ -64,7 +67,7 @@ Each package step:
| `-Version` | `2.4.1` | Version prefix (`VersionPrefix`). Stable packs with empty suffix; `-Preview` appends `preview-{timestamp}`. |
| `-Preview` | `false` | Pack/push `{Version}-preview-{yyyyMMddHHmmss}` to Nexus only (refuses nuget.org). |
| `-NuGetSource` | Internal Nexus URL | Target feed for `dotnet nuget push`. |
-| `-ApiKey` | `(from build.proj)` | API key for push. |
+| `-ApiKey` | `(from build.proj)` | API key for push. For nuget.org, create a token as in [NuGetApiToken.md](NuGetApiToken.md). |
| `-SkipSign` | `false` | Skip `SignAssemblies` target. |
| `-SkipPush` | `false` | Build & pack only; do not push. |
| `-DryRun` | `false` | Print commands without executing. |