testcases/deploymentTemplate/Template-Should-Not-Contain-Blanks.test.ps1

param(
    [Parameter(Mandatory = $true)]
    [string]
    $TemplateText
)

# Check for any text to remove empty property values - PowerShell handles empty differently in objects so check the JSON source (i.e. text)
# Empty strings, arrays, objects and null property values are not allowed, they have specific meaning in a declarative model
# the part of the expression '(?<=:)' is a back reference that means the expression must follow a colon,
# but the colon is not part of the match
# this ensures that the $PropertiesThatCanBeEmpty exceptions don't include the colon in the property name when we search
# the nearby context below

$colon = "(?<=:)\s{0,}" # this a back reference for a colon followed by 0 to more whitespace

$emptyItems = @([Regex]::Matches($TemplateText, "${colon}\{\s{0,}\}")) + # Empty objects
              @([Regex]::Matches($TemplateText, "${colon}\[\s{0,}\]")) + # empty arrays
              @([Regex]::Matches($TemplateText, "${colon}`"\s{0,}`"")) + # empty strings
              @([Regex]::Matches($TemplateText, "${colon}null"))

# TODO: This test will flag things like json('null') - that needs to be fixed before we add a check for null
# @([Regex]::Matches($TemplateText, 'null')) # null json property value

$lineBreaks = [Regex]::Matches($TemplateText, "`n|$([Environment]::NewLine)")

# Some properties can be empty for readability
$PropertiesThatCanBeEmpty = 'resources',
                            'outputs',
                            'variables',
                            'parameters',
                            'functions',
                            'properties',
                            'defaultValue', # enables optional parameters
                            'accessPolicies',  # keyVault requires this
                            'value', # Microsoft.Resources/deployments - passing empty strings to a nested deployment
                            'promotionCode', # Microsoft.OperationsManagement/soltuions/plan object
                            'inputs', # Microsoft.Portal/dashboard
                            'notEquals', # Microsoft.Authorization/policyDefinitions policyRule'
                            'clientId', # Microsoft.ContainerService/managedClusters.properties.servicePrincipalProfile
                            'allowedCallerIpAddresses' # Microsoft.Logic/workflows Access Control

if ($emptyItems) {
    foreach ($emptyItem in $emptyItems) {
        $nearbyContext = [Regex]::new('"(?<PropertyName>[^"]{1,})"\s{0,}:', "RightToLeft").Match($TemplateText, $emptyItem.Index)
        if ($nearbyContext -and $nearbyContext.Success) {
            $emptyPropertyName = $nearbyContext.Groups["PropertyName"].Value
            # exceptions
            if ($PropertiesThatCanBeEmpty -contains $emptyPropertyName) {
                continue
            }
            # userAssigned Identity can have an expression for the property name
            # it could also be a literal resourceId
            if ($emptyPropertyName -match "\s{0,}\[" -or              # an expression starts with [
                $emptyPropertyName -match "\s{0,}\/subscriptions\/"){ # a resourceId starts with /subscriptions/
                continue
            }
            $lineNumber = @($lineBreaks | ? { $_.Index -lt $emptyItem.Index }).Count + 1
            Write-Error "Empty property: $emptyItem found on line: $lineNumber" -TargetObject $emptyItem
        } 
    }
}