Subchapter 16.2
references/tfpolicy-test.mdMarkdown70 KBView on GitHub
Expert agent for testing Terraform policies. Helps write .policytest.hcl files, design resource and module mocks, use expect_failure correctly, mock cross-resource lookups for core::getresources(), and reason about current tfpolicy test runner behavior.
.policytest.hcl files, policytest { targets = [...] } blocks, resource {} / module {} mocks, or using expect_failure / skip.attrs and/or prior_attrs for create/update/delete scenarios.aws_s3_bucket_versioning for core::getresources patterns).operations scope, expect_failure not supported on data blocks, etc.).Do not use this skill when:
tfpolicy-author..policytest.hcl test — start with tfpolicy-author, then return here for test-side refinements.Generate a focused test file that exercises the passing and failing paths of a policy, including expect_failure = true cases and any required prior_attrs mocks. For policies that use input blocks, generate separate test files per input scenario using inputs {} (plural) to override default values.
Build the resource {} blocks needed for core::getresources() filters to match correctly (parent + child resources, skip = true on lookup-only resources, etc.).
Explain why a mock fails, passes, or crashes. Cover the current caveats: operations scope is not yet honored by the runner, expect_failure is rejected on data blocks, omitted attributes crash unless wrapped in core::try().
Decide when to split into multiple .policytest.hcl files (per-policy targeting) versus consolidating, and how to keep mocks aligned with the policy’s actual evaluation target.
For every generated test file, mandate test cases covering:
core::try())[] or empty map {} where the policy expects a non-empty valuenull. ⚠️ This case must only be included when the rules below permit it — do not add it unconditionally.Every generated .policytest.hcl must include at least one expect_failure = true resource that exercises a missing-attribute scenario.
Before finalizing each test case, verify polarity against the policy’s condition. For each expect_failure = true case, confirm the condition evaluates to false for that mock. For each pass case (no expect_failure), confirm it evaluates to true. A fail case the policy actually passes produces Missing expected failure; a pass case the policy actually fails produces an unexpected violation.
🔴 Null test case decision rules:
Key fact:
core::try(attrs.field, default)triggers the fallback only when the attribute key is absent. When a mock explicitly setsattrs.field = null,core::tryreturnsnull— not the default. This asymmetry means anullcase and an omitted-attribute case exercise different code paths.
For every attribute you are about to set to null in a test mock — regardless of attribute name, nesting level, or resource type — apply this check before adding the case:
raw = core::try(attrs.field, null) then val = raw != null ? raw : []) — null is explicitly normalized to a safe default. Add a pass case (no expect_failure) with the attribute set to null to verify this normalization works.condition = val != null && val != "") — null is intentionally treated as non-compliant. Add a fail case (expect_failure = true) with the attribute set to null.val = core::try(attrs.field, [])) without an explicit null guard — null is not normalized and will crash downstream expressions (for val in null, core::length(null)). Do NOT add a null case. Fix the policy to use the two-step pattern instead.Never add fail_*_null when the policy treats null the same as the safe default (false / []). A null case that the policy silently normalizes to compliant produces a Missing expected failure error — caused by the test itself, not a real policy bug.
The three rules above apply at every nesting level. For a scalar accessed as core::try(local.list[0].attr, default), apply the same single-step / two-step determination at that specific access point.
When the policy under test uses core::getresources(resource_type, filter), the test runner resolves the lookup against the resources declared in the same .policytest.hcl file. For the lookup to return the expected results:
attrs.<linking_attr> of the parent resource at evaluation time. Resource names in the mock do not affect matching — only attribute values do.skip = true on companion resources. Resources that should be visible to core::getresources() but must not be evaluated directly by the policy should be declared with skip = true. Without this, the runner evaluates them as standalone resources, which may produce unexpected results.core::getresources() returns an empty list — verify that the policy handles this as intended (e.g. treats the parent as non-compliant if a companion is required, or compliant if companion is optional).filter on the policy affects ALL resources in the test file when companions are present. When the policy uses a top-level core::getresources()-based filter (e.g. filter = core::length(local.all_companions) > 0), every parent resource in the test file is evaluated as soon as any companion resource is declared in that file. An unlinked parent (is_linked = false) then fails is_linked && check_attr == VALUE → violation — even if check_attr has the correct value. Do NOT include a “pass because not linked” case in the same file as companion resources. Omit it entirely; the meaningful cases are: (a) linked + correct attr → pass, (b) linked + wrong attr → fail. If the policy uses filter = local.has_qualifying (qualifying companions only), unlinked parents are not evaluated when no qualifying companions exist — but they still get evaluated when qualifying companions are present, so the same rule applies: no “unlinked pass” case in a file that also declares companion resources.Best practice: every generated .policytest.hcl should include an explicit policytest { targets = ["<policy-file>.policy.hcl"] } block when multiple policies exist in the same directory. Without it, tfpolicy test evaluates the mock against every policy in the directory — causing a test written for one policy to run against a different policy, producing misleading pass/fail results.
When generating test cases for policies that use core::try(attr, default), confirm that the default value in the policy matches the intended behavior for absent or null attributes. A wrong default silently passes resources that should fail. For each boolean flag or enum attribute, add a comment in the test explaining the expected behavior when the attribute is omitted versus explicitly set to null.
The bulk of this skill is the testing guide:
Cross-cutting facts shared with the sibling skills live in:
verified-syntax.md — verified Terraform Policy syntax, function names, runtime limitations. Anything in conflict with the testing guide should defer to this file.tfpolicy-author — write the policy under test.tfpolicy-author — migrate Sentinel tests alongside the policies.# Optional: specify which policy files to test
policytest {
targets = ["policy-file-1.policy.hcl", "policy-file-2.policy.hcl"]
}
# Mock resources with expected outcomes
resource "aws_s3_bucket" "passing_bucket" {
attrs = {
bucket = "my-secure-bucket"
versioning = [{ enabled = true }]
}
}
resource "aws_s3_bucket" "failing_bucket" {
expect_failure = true
attrs = {
bucket = "my-insecure-bucket"
versioning = [{ enabled = false }]
}
}Full test file structure with all supported blocks:
policytest {
targets = ["policy-file.policy.hcl"]
}
# Override input defaults for this test file
inputs {
port = 90
}
resource "aws_security_group_rule" "should_fail_port_90" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}Key Points:
policytest { targets = ["<policy-file>.policy.hcl"] } when multiple policies exist in the same directory. Without it, tfpolicy test evaluates the mock against every policy in the directory — a test resource intended for one policy will silently run against others, producing misleading results. expect_failure = true on such a resource can pass for the wrong reason.expect_failure = true applies to ALL policies evaluating the resourceexpect_failure is ONLY valid on resource {} blocks — using it on data {} blocks causes Unsupported argument error⚠️ Critical:
tfpolicy testdoes NOT evaluateerror_message
tfpolicy testonly evaluates theconditionexpression. It never evaluates or interpolates theerror_messagestring. This means:
- A policy with
error_message = "Failed: ${meta.address}"will pass alltfpolicy testruns even thoughmeta.addressis UNDEFINED and will crash every resource at runtime.- Only
terraform plan --policies=evaluateserror_message— always validate generated policies against a real Terraform plan to catch this class of bug.
core::try() to handle missing attributesImportant schema-verification caveat:
tfpolicy validate --policies=... performs provider schema acquisition and validates resource types against the providers declared in policy.required_providers.tfpolicy test --policies=... --tests=... can still emit:
Warning: Resource types not verified against provider schemas
even when validate succeeded separately.tfpolicy test alone to catch misspelled or unknown resource types. Always run tfpolicy validate as a separate step before or alongside tests.tfpolicy test, even when all individual test cases show pass.CI/CD Usage:
tfpolicy validate --policies=./policies
tfpolicy test --policies=./policies --tests=./tests
if [ $? -eq 0 ]; then echo "Passed"; else echo "Failed"; exit 1; firesource "resource_type" "test_name" {
expect_failure = true/false # Optional — ONLY valid on resource blocks, NOT data blocks
skip = true/false # Optional
attrs = {
# All resource attributes
}
}⚠️
expect_failureis NOT supported ondata {}blocks. Using it on a data block causesUnsupported argument "expect_failure". Only mockresource {}blocks support this attribute.
CRITICAL: Attributes accessed by policies MUST be provided in test mocks or wrapped with core::try().
Direct Access (Causes Error):
# Policy
resource_policy "aws_ebs_volume" "check" {
enforce {
condition = attrs.encrypted == true # Direct access
}
}
# Test - ERROR if encrypted is omitted
resource "aws_ebs_volume" "test" {
attrs = {
size = 100
# encrypted omitted - causes "This object does not have an attribute named 'encrypted'"
}
}Safe Access with core::try():
# Policy
resource_policy "aws_ebs_volume" "check" {
locals {
encrypted = core::try(attrs.encrypted, false) # Safe access
}
enforce {
condition = local.encrypted == true
}
}
# Test - Works even if encrypted is omitted
resource "aws_ebs_volume" "test" {
attrs = {
size = 100
# encrypted omitted - core::try() returns false (default value)
}
}Rule: Only attributes NOT accessed by the policy can be safely omitted. All accessed attributes must either:
attrs = {} block, ORcore::try() in the policyPolicies can scope themselves to specific plan operations via operations = ["create", "update", "delete"]. Test mocks support a matching prior_attrs = { ... } block alongside attrs = { ... }, so create, update, and delete-gate policies are all fully testable.
Mock shape per operation:
| Operation being tested | Provide attrs | Provide prior_attrs |
|---|---|---|
create | ✅ planned values | — |
update | ✅ planned values | ✅ pre-change values |
delete | — | ✅ pre-change values |
Create / update policy (planned values only):
# Policy
resource_policy "tfe_workspace" "require_project" {
operations = ["create", "update"]
enforce {
condition = core::try(attrs.project_id, "") != ""
error_message = "tfe_workspace must have project_id set."
}
}# Test
policytest { targets = ["workspace-require-project.policy.hcl"] }
resource "tfe_workspace" "with_project" {
attrs = { project_id = "prj-123" }
}
resource "tfe_workspace" "missing_project" {
expect_failure = true
attrs = {}
}Update policy (reads both attrs and prior_attrs):
# Policy — block downgrades
resource_policy "tfe_workspace" "no_downgrade" {
operations = ["update"]
enforce {
condition = core::try(attrs.terraform_version, "") == core::try(prior_attrs.terraform_version, "")
|| core::try(attrs.terraform_version, "") > core::try(prior_attrs.terraform_version, "")
error_message = "terraform_version downgrade is not allowed."
}
}# Test
policytest { targets = ["workspace-no-downgrade.policy.hcl"] }
resource "tfe_workspace" "upgrade_ok" {
attrs = { terraform_version = "1.10.0" }
prior_attrs = { terraform_version = "1.9.0" }
}
resource "tfe_workspace" "downgrade_blocked" {
expect_failure = true
attrs = { terraform_version = "1.5.0" }
prior_attrs = { terraform_version = "1.9.0" }
}Delete-gate policy (pre-change state only):
# Policy
resource_policy "tfe_workspace" "deny_delete_without_tag" {
operations = ["delete"]
locals {
prior_tag_names = core::try(prior_attrs.tag_names, [])
}
enforce {
condition = core::contains(local.prior_tag_names, "delete")
error_message = "Add 'delete' tag before destroying a workspace."
}
}# Test
policytest { targets = ["workspace-deny-delete-without-tag.policy.hcl"] }
resource "tfe_workspace" "has_delete_tag" {
prior_attrs = { tag_names = ["delete", "prod"] }
}
resource "tfe_workspace" "missing_delete_tag" {
expect_failure = true
prior_attrs = { tag_names = ["prod"] }
}Note: The runner currently evaluates each mock against every policy listed in
targetsregardless of the policy’soperationsscope. Keep your.policytest.hclfile targeted at a single policy (or a set of policies that share the same operation scope), and only supply theattrs/prior_attrsfields that policy actually reads.
The structure of attrs = {} depends on provider schema. Consult provider docs to determine if attributes are blocks or direct values.
Example - Blocks vs Attributes:
resource "aws_instance" "test" {
attrs = {
instance_type = "t2.micro" # Direct attribute
# Block (requires array of maps)
default_tags = [{
tags = {
Environment = "Production"
}
}]
}
}In test files: Use = for blocks (not {} syntax used in policy files)
Reference other test resources within the SAME file using resource_type.name.attrs.attribute:
resource "aws_security_group" "app_sg" {
attrs = {
name = "app-security-group"
}
}
resource "aws_instance" "app_server" {
attrs = {
vpc_security_group_ids = [aws_security_group.app_sg.attrs.name]
}
}Limitation: References cannot span across test files.
Resources with skip = true:
core::getresources() resultsresource "aws_ebs_volume" "available_for_reference" {
skip = true
attrs = {
volume_id = "vol-12345"
}
}
resource "aws_instance" "server" {
attrs = {
ebs_block_device = [{
volume_id = aws_ebs_volume.available_for_reference.attrs.volume_id
}]
}
}Use skip only when: Resource is referenced or needed in getresources() counts.
If a resource doesn’t match the filter, it’s NOT evaluated (test passes):
# Policy with filter
resource_policy "aws_s3_bucket" {
filter = attrs.bucket_prefix == "secure-"
enforce {
condition = attrs.versioning[0].enabled == true
}
}
# Test - doesn't match filter, so passes
resource "aws_s3_bucket" "filtered_out" {
attrs = {
bucket_prefix = "public-" # Doesn't match filter
versioning = [{ enabled = false }]
}
}Best Practice: Test both resources that match and don’t match the filter.
IMPORTANT: Meta attributes for resource_policy behave differently in mock tests vs real terraform plan evaluation.
Available Meta Attributes by Evaluation Mode:
| Meta Attribute | Mock Tests (tfpolicy test) | Real Plans (terraform plan --policies=) |
|---|---|---|
meta.provider_type | ❌ UNDEFINED | ✅ Available (e.g., “aws”, “azurerm”) |
meta.type | ❌ UNDEFINED | ❌ UNDEFINED |
meta.address | ❌ UNDEFINED | ❌ UNDEFINED |
Example:
# Policy using meta.provider_type
resource_policy "aws_ebs_volume" "check_provider" {
enforce {
condition = core::try(meta.provider_type, "UNDEFINED") == "aws"
error_message = "Provider type: ${core::try(meta.provider_type, "UNDEFINED")}"
}
}Test behavior:
tfpolicy test: meta.provider_type returns UNDEFINED (test may fail)terraform plan --policies=: meta.provider_type returns “aws” (test passes)Best Practice: When using meta.provider_type in policies, always wrap with core::try() and note that mock tests cannot fully validate this behavior. Test with real terraform plans for complete validation.
module "source" "test_name" {
expect_failure = true/false # Optional
meta = {
source = "registry.terraform.io/namespace/name"
address = "module.name"
version = "1.0.0"
}
}Available meta attributes:
source - Module sourceaddress - Module address (e.g., “module.database”)version - Module versionNote: Modules use meta only (no attrs)
Policy:
locals {
allowed_sources = [
"registry.terraform.io/hashicorp/aws",
"registry.terraform.io/terraform-aws-modules/vpc/aws"
]
}
module_policy "*" "approved_sources" {
filter = meta.source != null
enforce {
condition = core::contains(local.allowed_sources, meta.source)
error_message = "Unauthorized module source: ${meta.source}"
}
}Test:
# Passing
module "registry.terraform.io/hashicorp/aws" "approved" {
meta = {
source = "registry.terraform.io/hashicorp/aws"
address = "module.database"
version = "1.0.0"
}
}
# Failing
module "registry.terraform.io/acme-corp/database" "unauthorized" {
expect_failure = true
meta = {
source = "registry.terraform.io/acme-corp/database"
address = "module.db"
version = "2.0.0"
}
}Policy:
module_policy "registry.terraform.io/hashicorp/aws" "version_check" {
filter = meta.source == "registry.terraform.io/hashicorp/aws"
enforce {
condition = core::semverconstraint(meta.version, ">= 4.0.0")
error_message = "Module must use version >= 4.0.0, found ${meta.version}"
}
}provider "type" "test_name" {
expect_failure = true/false # Optional
meta = {
source = "registry.terraform.io/namespace/name"
}
}Available meta attributes:
source - Provider source (e.g., “registry.terraform.io/hashicorp/aws”)Note: Provider type (e.g., “aws”) goes in block declaration, not meta.
Policy:
locals {
allowed_provider_sources = [
"registry.terraform.io/hashicorp/aws",
"registry.terraform.io/hashicorp/azurerm"
]
}
provider_policy "aws" {
enforce {
condition = core::contains(local.allowed_provider_sources, meta.source)
error_message = "Provider source '${meta.source}' is not approved"
}
}Test:
# Passing
provider "aws" "official" {
meta = {
source = "registry.terraform.io/hashicorp/aws"
}
}
# Failing
provider "aws" "unofficial" {
expect_failure = true
meta = {
source = "registry.terraform.io/acme-corp/aws"
}
}meta.source == "registry.terraform.io/hashicorp/aws"core::semverconstraint(meta.version, ">= 4.0.0, < 6.0.0")data "aws_ami" "ubuntu" {
attrs = {
id = "ami-12345"
name = "ubuntu-20.04"
}
}
# Policy can reference it
resource_policy "aws_instance" {
enforce {
condition = attrs.ami == data.aws_ami.ubuntu.attrs.id
}
}❌ Not Available: terraform.workspace or workspace context
Valid traversal roots:
input - Input variableslocal - Local variablesattrs - Resource/data source attributesmeta - MetadataWorkarounds:
resource "aws_security_group" "multiple_rules" {
attrs = {
ingress = [
{
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]
}
}Only works if policy uses core::try():
# Policy must use core::try() to handle missing attributes
resource_policy "aws_s3_bucket" "check" {
locals {
encryption = core::try(attrs.server_side_encryption_configuration, null)
}
enforce {
condition = local.encryption != null
error_message = "Encryption required"
}
}
# Test - omitted attribute handled by core::try()
resource "aws_s3_bucket" "no_encryption" {
expect_failure = true
attrs = {
bucket = "my-bucket"
# server_side_encryption_configuration omitted - handled by core::try()
}
}Without core::try(), omitting accessed attributes causes evaluation errors.
Policies that use input blocks can have their input values overridden per test file using an inputs {} block (plural). This allows you to test the policy behaviour under different configurations without changing the policy itself.
⚠️ The block is
inputs {}(plural) — usinginput {}(singular) throwsUnsupported block typeerror.
# Policy (test.policy.hcl)
input "port" {
type = number
default = 22
}
resource_policy "aws_security_group_rule" "no_open_ingress" {
filter = core::try(attrs.type, "") == "ingress"
locals {
covers_port = core::try(attrs.from_port <= input.port && attrs.to_port >= input.port, false)
}
enforce {
condition = !local.covers_port
error_message = "Ingress rule covers restricted port ${input.port}."
}
}# Test file 1: test with default port (22)
policytest {
targets = ["test.policy.hcl"]
}
# No inputs block — uses input.port default = 22
# PASS: port range 80-443 does not cover default port 22
resource "aws_security_group_rule" "pass_default_port" {
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
}
}
# FAIL: port range 1-1024 covers default port 22
resource "aws_security_group_rule" "fail_default_port" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 1
to_port = 1024
protocol = "tcp"
}
}# Test file 2: test with custom port (90)
policytest {
targets = ["test.policy.hcl"]
}
inputs {
port = 90 # override default of 22
}
# FAIL: port range 80-443 DOES cover custom port 90
resource "aws_security_group_rule" "fail_custom_port" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
}
}Key points:
inputs {} block with different values — use separate .policytest.hcl files per input scenarioinputs {} block is present, the policy’s default values are usedinputs {} in test files is for test-time validation onlyEvery generated .policytest.hcl must include test cases for the following scenarios. Missing any of these is a test coverage gap:
| Scenario | Mock pattern | Why it matters |
|---|---|---|
| Missing attribute (omitted entirely) | attrs = { bucket = "x" } — target attribute not present | Crashes policies that don’t use core::try(); most common real-world gap |
| Null attribute | attrs = { ..., field = null } | Tests core::try() default handling |
| Empty list | attrs = { ..., items = [] } | Policies expecting non-empty collections must handle [] |
| Empty string | attrs = { ..., value = "" } | String-check policies must not treat "" as compliant |
| Boundary value | Exact threshold (e.g. port = 443, count = max_allowed) | Off-by-one errors in range/count checks |
# ✅ Missing attribute — must fail (tests core::try() default)
resource "aws_s3_bucket" "missing_encryption" {
expect_failure = true
attrs = {
bucket = "test-bucket"
# server_side_encryption_configuration intentionally omitted
}
}
# ✅ Empty list — must fail
resource "aws_s3_bucket" "empty_encryption_rules" {
expect_failure = true
attrs = {
bucket = "test-bucket"
server_side_encryption_configuration = []
}
}encrypted_volume_passes not test1policies/
├── cis-4.1-deny-public-ssh.policy.hcl
├── cis-4.1-deny-public-ssh.policytest.hcl
├── cis-4.2-deny-public-rdp.policy.hcl
└── cis-4.2-deny-public-rdp.policytest.hcl| Policy Type | Test Block | Available Attributes |
|---|---|---|
| resource_policy | resource "type" "name" { attrs = {...} } | attrs.*, meta.provider_type (real plans only) |
| module_policy | module "source" "name" { meta = {...} } | meta.source, meta.address, meta.version |
| provider_policy | provider "type" "name" { meta = {...} } | meta.source |
| data source | data "type" "name" { attrs = {...} } | attrs.* |
| Feature | Syntax | Scope |
|---|---|---|
| Test target | policytest { targets = ["file.policy.hcl"] } | Optional (best practice when multiple policies in dir) |
| Override input values | inputs { key = value } | Per test file — uses policy default if omitted |
| Expected failure | expect_failure = true | All policies |
| Skip evaluation | skip = true | Resources only |
| Cross-resource ref | resource_type.name.attrs.attribute | Same file only |
| Omitted attributes | Don’t specify in attrs = {} | Causes error unless policy uses core::try() |
Problem: Need to enforce that every S3 bucket has a corresponding encryption configuration.
Solution: Evaluate buckets, look up encryption configs via core::getresources().
# Top-level: Get all encryption configs once
locals {
all_encryption_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {})
}
# Resource-level: Find matching config for each bucket
resource_policy "aws_s3_bucket" "require_encryption" {
locals {
matching_configs = [
for config in local.all_encryption_configs :
config if config.bucket == attrs.bucket
]
}
enforce {
condition = core::try(local.matching_configs[0], null) != null
error_message = "Bucket must have encryption config"
}
}Test Structure:
# Evaluated resource - NO skip
resource "aws_s3_bucket" "test" {
attrs = { bucket = "test" }
}
# Looked-up resource - YES skip (but still visible to core::getresources)
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
skip = true
attrs = {
bucket = aws_s3_bucket.test.bucket
rule = [{ ... }] # Must be array!
}
}Key Points:
skip = true ARE visible to core::getresources()locals for core::getresources() (performance)Problem: Need to check two related attributes to determine compliance.
Example: S3 must use customer-managed KMS keys (not AWS-managed or AES256).
AWS encryption types:
Why one check fails:
# ❌ Only checks algorithm
condition = attrs.sse_algorithm == "aws:kms"
# PASSES even without kms_master_key_id (uses AWS-managed key!)
# ❌ Only checks key ID
condition = attrs.kms_master_key_id != ""
# PASSES even with "AES256" algorithm (not using KMS!)Correct: Check both
locals {
sse_algorithm = core::try(attrs.encryption[0].sse_algorithm, "")
kms_key_id = core::try(attrs.encryption[0].kms_master_key_id, "")
}
enforce {
condition = local.sse_algorithm == "aws:kms" && local.kms_key_id != ""
error_message = "Must use customer-managed KMS. Found algorithm: '${local.sse_algorithm}', key specified: ${local.kms_key_id != ""}"
}Discovery: With two-policy approach (one policy for buckets, another for encryption configs), test files fail when they contain 5+ buckets.
Workaround 1: Use single-policy approach (no limit observed)
# ✅ One policy evaluates buckets, looks up configs
resource_policy "aws_s3_bucket" "require_encryption" {
# Can test 6+ buckets in single file
}Workaround 2: Split tests across multiple files (max 4 buckets each)
tests/
├── test-scenario-1.policytest.hcl # 4 buckets
├── test-scenario-2.policytest.hcl # 4 buckets
└── test-scenario-3.policytest.hcl # 4 bucketsMistake: Wrong resource has skip or expect_failure
# ❌ WRONG - Policy evaluates buckets but test skips them
resource "aws_s3_bucket" "test" {
skip = true # Policy can't evaluate this!
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
expect_failure = true # Policy doesn't evaluate this!
}
# ✅ CORRECT - Match policy evaluation target
resource "aws_s3_bucket" "test" {
expect_failure = true # Policy evaluates buckets
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
skip = true # Policy looks this up via core::getresources()
}Mistake: Using objects instead of arrays
# ❌ WRONG
attrs = {
rule = { key = "value" } # Object
}
# ✅ CORRECT
attrs = {
rule = [{ key = "value" }] # Array
}Reason: Terraform resources use arrays. Policies access attrs.rule[0].
These are behaviors where tfpolicy test passes silently but a real terraform plan --policies= run fails or behaves differently. Always verify port-range and integer-arithmetic policies against a real plan.
core::range(start, end) works correctly when called with hardcoded integer literals. However, when start or end come from mocked attrs.* integer values (e.g. attrs.from_port, attrs.to_port), the policytest framework treats those values as unknown/unevaluated at test time and core::range() silently returns an empty list [].
Impact: A policy that uses core::range() with dynamic port attributes will appear to pass all tests — including expect_failure cases — because the range is always empty. The bug only surfaces against a real plan.
⚠️
core::alltrue()andcore::anytrue()do NOT exist in tfpolicy runtime. Using them will produceError: Call to unknown function / There is no function named "alltrue" in namespace core::.. The examples below show the problem pattern (❌) and the correct alternative (✅).
# ❌ WRONG — core::range() + core::alltrue() — both problematic
locals {
ports_in_range = core::range(core::try(attrs.from_port, 0), core::try(attrs.to_port, 0) + 1)
# ↑ returns [] in policytest because attrs.from_port/to_port are unknown at test time
all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)])
# ↑ core::alltrue does NOT exist — will error; also core::range() returns [] here
}Fix: Use the count approach instead — it works correctly with dynamic attrs.* values in both policytest and real plans:
# ✅ Count approach — consistent in policytest and real plan evaluation
locals {
authorized_ports = [80, 443]
from_port = core::try(attrs.from_port, 0)
to_port = core::try(attrs.to_port, 0)
authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port]
all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1)
}See verified-syntax.md Mistake 23 for the full pattern.
In tfpolicy test, when a policy calls core::getresources("some_type", filter), the lookup searches all mock resources of that type in the entire test file — including resources marked expect_failure = true and resources marked skip = true.
Impact: A test scenario that requires core::getresources() to return zero results (or no compliant results) will silently produce the wrong outcome if any other scenario in the same file defines a resource of that type that satisfies the filter.
# ❌ PROBLEMATIC — both scenarios in the same test file
# "fail_no_defaults" incorrectly passes because core::getresources() picks up
# the compliant_defaults resource from the other scenario.
resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" {
skip = true # skip = true is still visible to core::getresources()!
attrs = { http_tokens = "required" }
}
resource "aws_instance" "pass_with_defaults" {
attrs = { instance_type = "t3.micro" }
}
resource "aws_instance" "fail_no_defaults" {
expect_failure = true
attrs = { instance_type = "t3.micro" }
# WRONG: core::getresources("aws_ec2_instance_metadata_defaults", ...) still sees
# "compliant_defaults" above → policy evaluates as compliant → expect_failure passes incorrectly.
}Fix: Place scenarios with conflicting core::getresources() context into separate .policytest.hcl files. Each file is an independent resource graph.
# ✅ File 1: test-with-compliant-defaults.policytest.hcl
resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" {
skip = true
attrs = { http_tokens = "required" }
}
resource "aws_instance" "pass_with_defaults" {
attrs = { instance_type = "t3.micro" }
}
# ✅ File 2: test-no-defaults.policytest.hcl
# No aws_ec2_instance_metadata_defaults defined — core::getresources() returns empty list.
resource "aws_instance" "fail_no_defaults" {
expect_failure = true
attrs = { instance_type = "t3.micro" }
}Rule: Whenever a test scenario relies on core::getresources() returning zero results (or no compliant results) for a given type, that scenario must be in its own .policytest.hcl file, completely isolated from any scenario that defines resources of that same type.
# Optional: specify which policy files to test
policytest {
targets = ["policy-file-1.policy.hcl", "policy-file-2.policy.hcl"]
}
# Mock resources with expected outcomes
resource "aws_s3_bucket" "passing_bucket" {
attrs = {
bucket = "my-secure-bucket"
versioning = [{ enabled = true }]
}
}
resource "aws_s3_bucket" "failing_bucket" {
expect_failure = true
attrs = {
bucket = "my-insecure-bucket"
versioning = [{ enabled = false }]
}
}Full test file structure with all supported blocks:
policytest {
targets = ["policy-file.policy.hcl"]
}
# Override input defaults for this test file
inputs {
port = 90
}
resource "aws_security_group_rule" "should_fail_port_90" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}Key Points:
policytest { targets = ["<policy-file>.policy.hcl"] } when multiple policies exist in the same directory. Without it, tfpolicy test evaluates the mock against every policy in the directory — a test resource intended for one policy will silently run against others, producing misleading results. expect_failure = true on such a resource can pass for the wrong reason.expect_failure = true applies to ALL policies evaluating the resourceexpect_failure is ONLY valid on resource {} blocks — using it on data {} blocks causes Unsupported argument error⚠️ Critical:
tfpolicy testdoes NOT evaluateerror_message
tfpolicy testonly evaluates theconditionexpression. It never evaluates or interpolates theerror_messagestring. This means:
- A policy with
error_message = "Failed: ${meta.address}"will pass alltfpolicy testruns even thoughmeta.addressis UNDEFINED and will crash every resource at runtime.- Only
terraform plan --policies=evaluateserror_message— always validate generated policies against a real Terraform plan to catch this class of bug.
core::try() to handle missing attributesCI/CD Usage:
tfpolicy test --policies=./policies --tests=./tests
if [ $? -eq 0 ]; then echo "Passed"; else echo "Failed"; exit 1; firesource "resource_type" "test_name" {
expect_failure = true/false # Optional — ONLY valid on resource blocks, NOT data blocks
skip = true/false # Optional
attrs = {
# All resource attributes
}
}⚠️
expect_failureis NOT supported ondata {}blocks. Using it on a data block causesUnsupported argument "expect_failure". Only mockresource {}blocks support this attribute.
CRITICAL: Attributes accessed by policies MUST be provided in test mocks or wrapped with core::try().
Direct Access (Causes Error):
# Policy
resource_policy "aws_ebs_volume" "check" {
enforce {
condition = attrs.encrypted == true # Direct access
}
}
# Test - ERROR if encrypted is omitted
resource "aws_ebs_volume" "test" {
attrs = {
size = 100
# encrypted omitted - causes "This object does not have an attribute named 'encrypted'"
}
}Safe Access with core::try():
# Policy
resource_policy "aws_ebs_volume" "check" {
locals {
encrypted = core::try(attrs.encrypted, false) # Safe access
}
enforce {
condition = local.encrypted == true
}
}
# Test - Works even if encrypted is omitted
resource "aws_ebs_volume" "test" {
attrs = {
size = 100
# encrypted omitted - core::try() returns false (default value)
}
}Rule: Only attributes NOT accessed by the policy can be safely omitted. All accessed attributes must either:
attrs = {} block, ORcore::try() in the policyPolicies can scope themselves to specific plan operations via operations = ["create", "update", "delete"]. Test mocks support a matching prior_attrs = { ... } block alongside attrs = { ... }, so create, update, and delete-gate policies are all fully testable.
Mock shape per operation:
| Operation being tested | Provide attrs | Provide prior_attrs |
|---|---|---|
create | ✅ planned values | — |
update | ✅ planned values | ✅ pre-change values |
delete | — | ✅ pre-change values |
Create / update policy (planned values only):
# Policy
resource_policy "tfe_workspace" "require_project" {
operations = ["create", "update"]
enforce {
condition = core::try(attrs.project_id, "") != ""
error_message = "tfe_workspace must have project_id set."
}
}# Test
policytest { targets = ["workspace-require-project.policy.hcl"] }
resource "tfe_workspace" "with_project" {
attrs = { project_id = "prj-123" }
}
resource "tfe_workspace" "missing_project" {
expect_failure = true
attrs = {}
}Update policy (reads both attrs and prior_attrs):
# Policy — block downgrades
resource_policy "tfe_workspace" "no_downgrade" {
operations = ["update"]
enforce {
condition = core::try(attrs.terraform_version, "") == core::try(prior_attrs.terraform_version, "")
|| core::try(attrs.terraform_version, "") > core::try(prior_attrs.terraform_version, "")
error_message = "terraform_version downgrade is not allowed."
}
}# Test
policytest { targets = ["workspace-no-downgrade.policy.hcl"] }
resource "tfe_workspace" "upgrade_ok" {
attrs = { terraform_version = "1.10.0" }
prior_attrs = { terraform_version = "1.9.0" }
}
resource "tfe_workspace" "downgrade_blocked" {
expect_failure = true
attrs = { terraform_version = "1.5.0" }
prior_attrs = { terraform_version = "1.9.0" }
}Delete-gate policy (pre-change state only):
# Policy
resource_policy "tfe_workspace" "deny_delete_without_tag" {
operations = ["delete"]
locals {
prior_tag_names = core::try(prior_attrs.tag_names, [])
}
enforce {
condition = core::contains(local.prior_tag_names, "delete")
error_message = "Add 'delete' tag before destroying a workspace."
}
}# Test
policytest { targets = ["workspace-deny-delete-without-tag.policy.hcl"] }
resource "tfe_workspace" "has_delete_tag" {
prior_attrs = { tag_names = ["delete", "prod"] }
}
resource "tfe_workspace" "missing_delete_tag" {
expect_failure = true
prior_attrs = { tag_names = ["prod"] }
}Note: The runner currently evaluates each mock against every policy listed in
targetsregardless of the policy’soperationsscope. Keep your.policytest.hclfile targeted at a single policy (or a set of policies that share the same operation scope), and only supply theattrs/prior_attrsfields that policy actually reads.
The structure of attrs = {} depends on provider schema. Consult provider docs to determine if attributes are blocks or direct values.
Example - Blocks vs Attributes:
resource "aws_instance" "test" {
attrs = {
instance_type = "t2.micro" # Direct attribute
# Block (requires array of maps)
default_tags = [{
tags = {
Environment = "Production"
}
}]
}
}In test files: Use = for blocks (not {} syntax used in policy files)
Reference other test resources within the SAME file using resource_type.name.attrs.attribute:
resource "aws_security_group" "app_sg" {
attrs = {
name = "app-security-group"
}
}
resource "aws_instance" "app_server" {
attrs = {
vpc_security_group_ids = [aws_security_group.app_sg.attrs.name]
}
}Limitation: References cannot span across test files.
Resources with skip = true:
core::getresources() resultsresource "aws_ebs_volume" "available_for_reference" {
skip = true
attrs = {
volume_id = "vol-12345"
}
}
resource "aws_instance" "server" {
attrs = {
ebs_block_device = [{
volume_id = aws_ebs_volume.available_for_reference.attrs.volume_id
}]
}
}Use skip only when: Resource is referenced or needed in getresources() counts.
If a resource doesn’t match the filter, it’s NOT evaluated (test passes):
# Policy with filter
resource_policy "aws_s3_bucket" {
filter = attrs.bucket_prefix == "secure-"
enforce {
condition = attrs.versioning[0].enabled == true
}
}
# Test - doesn't match filter, so passes
resource "aws_s3_bucket" "filtered_out" {
attrs = {
bucket_prefix = "public-" # Doesn't match filter
versioning = [{ enabled = false }]
}
}Best Practice: Test both resources that match and don’t match the filter.
IMPORTANT: Meta attributes for resource_policy behave differently in mock tests vs real terraform plan evaluation.
Available Meta Attributes by Evaluation Mode:
| Meta Attribute | Mock Tests (tfpolicy test) | Real Plans (terraform plan --policies=) |
|---|---|---|
meta.provider_type | ❌ UNDEFINED | ✅ Available (e.g., “aws”, “azurerm”) |
meta.type | ❌ UNDEFINED | ❌ UNDEFINED |
meta.address | ❌ UNDEFINED | ❌ UNDEFINED |
Example:
# Policy using meta.provider_type
resource_policy "aws_ebs_volume" "check_provider" {
enforce {
condition = core::try(meta.provider_type, "UNDEFINED") == "aws"
error_message = "Provider type: ${core::try(meta.provider_type, "UNDEFINED")}"
}
}Test behavior:
tfpolicy test: meta.provider_type returns UNDEFINED (test may fail)terraform plan --policies=: meta.provider_type returns “aws” (test passes)Best Practice: When using meta.provider_type in policies, always wrap with core::try() and note that mock tests cannot fully validate this behavior. Test with real terraform plans for complete validation.
module "source" "test_name" {
expect_failure = true/false # Optional
meta = {
source = "registry.terraform.io/namespace/name"
address = "module.name"
version = "1.0.0"
}
}Available meta attributes:
source - Module sourceaddress - Module address (e.g., “module.database”)version - Module versionNote: Modules use meta only (no attrs)
Policy:
locals {
allowed_sources = [
"registry.terraform.io/hashicorp/aws",
"registry.terraform.io/terraform-aws-modules/vpc/aws"
]
}
module_policy "*" "approved_sources" {
filter = meta.source != null
enforce {
condition = core::contains(local.allowed_sources, meta.source)
error_message = "Unauthorized module source: ${meta.source}"
}
}Test:
# Passing
module "registry.terraform.io/hashicorp/aws" "approved" {
meta = {
source = "registry.terraform.io/hashicorp/aws"
address = "module.database"
version = "1.0.0"
}
}
# Failing
module "registry.terraform.io/acme-corp/database" "unauthorized" {
expect_failure = true
meta = {
source = "registry.terraform.io/acme-corp/database"
address = "module.db"
version = "2.0.0"
}
}Policy:
module_policy "registry.terraform.io/hashicorp/aws" "version_check" {
filter = meta.source == "registry.terraform.io/hashicorp/aws"
enforce {
condition = core::semverconstraint(meta.version, ">= 4.0.0")
error_message = "Module must use version >= 4.0.0, found ${meta.version}"
}
}provider "type" "test_name" {
expect_failure = true/false # Optional
meta = {
source = "registry.terraform.io/namespace/name"
}
}Available meta attributes:
source - Provider source (e.g., “registry.terraform.io/hashicorp/aws”)Note: Provider type (e.g., “aws”) goes in block declaration, not meta.
Policy:
locals {
allowed_provider_sources = [
"registry.terraform.io/hashicorp/aws",
"registry.terraform.io/hashicorp/azurerm"
]
}
provider_policy "aws" {
enforce {
condition = core::contains(local.allowed_provider_sources, meta.source)
error_message = "Provider source '${meta.source}' is not approved"
}
}Test:
# Passing
provider "aws" "official" {
meta = {
source = "registry.terraform.io/hashicorp/aws"
}
}
# Failing
provider "aws" "unofficial" {
expect_failure = true
meta = {
source = "registry.terraform.io/acme-corp/aws"
}
}meta.source == "registry.terraform.io/hashicorp/aws"core::semverconstraint(meta.version, ">= 4.0.0, < 6.0.0")data "aws_ami" "ubuntu" {
attrs = {
id = "ami-12345"
name = "ubuntu-20.04"
}
}
# Policy can reference it
resource_policy "aws_instance" {
enforce {
condition = attrs.ami == data.aws_ami.ubuntu.attrs.id
}
}❌ Not Available: terraform.workspace or workspace context
Valid traversal roots:
input - Input variableslocal - Local variablesattrs - Resource/data source attributesmeta - MetadataWorkarounds:
resource "aws_security_group" "multiple_rules" {
attrs = {
ingress = [
{
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]
}
}Only works if policy uses core::try():
# Policy must use core::try() to handle missing attributes
resource_policy "aws_s3_bucket" "check" {
locals {
encryption = core::try(attrs.server_side_encryption_configuration, null)
}
enforce {
condition = local.encryption != null
error_message = "Encryption required"
}
}
# Test - omitted attribute handled by core::try()
resource "aws_s3_bucket" "no_encryption" {
expect_failure = true
attrs = {
bucket = "my-bucket"
# server_side_encryption_configuration omitted - handled by core::try()
}
}Without core::try(), omitting accessed attributes causes evaluation errors.
Policies that use input blocks can have their input values overridden per test file using an inputs {} block (plural). This allows you to test the policy behaviour under different configurations without changing the policy itself.
⚠️ The block is
inputs {}(plural) — usinginput {}(singular) throwsUnsupported block typeerror.
# Policy (test.policy.hcl)
input "port" {
type = number
default = 22
}
resource_policy "aws_security_group_rule" "no_open_ingress" {
filter = core::try(attrs.type, "") == "ingress"
locals {
covers_port = core::try(attrs.from_port <= input.port && attrs.to_port >= input.port, false)
}
enforce {
condition = !local.covers_port
error_message = "Ingress rule covers restricted port ${input.port}."
}
}# Test file 1: test with default port (22)
policytest {
targets = ["test.policy.hcl"]
}
# No inputs block — uses input.port default = 22
# PASS: port range 80-443 does not cover default port 22
resource "aws_security_group_rule" "pass_default_port" {
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
}
}
# FAIL: port range 1-1024 covers default port 22
resource "aws_security_group_rule" "fail_default_port" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 1
to_port = 1024
protocol = "tcp"
}
}# Test file 2: test with custom port (90)
policytest {
targets = ["test.policy.hcl"]
}
inputs {
port = 90 # override default of 22
}
# FAIL: port range 80-443 DOES cover custom port 90
resource "aws_security_group_rule" "fail_custom_port" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
}
}Key points:
inputs {} block with different values — use separate .policytest.hcl files per input scenarioinputs {} block is present, the policy’s default values are usedinputs {} in test files is for test-time validation onlyEvery generated .policytest.hcl must include test cases for the following scenarios. Missing any of these is a test coverage gap:
| Scenario | Mock pattern | Why it matters |
|---|---|---|
| Missing attribute (omitted entirely) | attrs = { bucket = "x" } — target attribute not present | Crashes policies that don’t use core::try(); most common real-world gap |
| Null attribute | attrs = { ..., field = null } | Tests core::try() default handling |
| Empty list | attrs = { ..., items = [] } | Policies expecting non-empty collections must handle [] |
| Empty string | attrs = { ..., value = "" } | String-check policies must not treat "" as compliant |
| Boundary value | Exact threshold (e.g. port = 443, count = max_allowed) | Off-by-one errors in range/count checks |
# ✅ Missing attribute — must fail (tests core::try() default)
resource "aws_s3_bucket" "missing_encryption" {
expect_failure = true
attrs = {
bucket = "test-bucket"
# server_side_encryption_configuration intentionally omitted
}
}
# ✅ Empty list — must fail
resource "aws_s3_bucket" "empty_encryption_rules" {
expect_failure = true
attrs = {
bucket = "test-bucket"
server_side_encryption_configuration = []
}
}encrypted_volume_passes not test1policies/
├── cis-4.1-deny-public-ssh.policy.hcl
├── cis-4.1-deny-public-ssh.policytest.hcl
├── cis-4.2-deny-public-rdp.policy.hcl
└── cis-4.2-deny-public-rdp.policytest.hcl| Policy Type | Test Block | Available Attributes |
|---|---|---|
| resource_policy | resource "type" "name" { attrs = {...} } | attrs.*, meta.provider_type (real plans only) |
| module_policy | module "source" "name" { meta = {...} } | meta.source, meta.address, meta.version |
| provider_policy | provider "type" "name" { meta = {...} } | meta.source |
| data source | data "type" "name" { attrs = {...} } | attrs.* |
| Feature | Syntax | Scope |
|---|---|---|
| Test target | policytest { targets = ["file.policy.hcl"] } | Optional (best practice when multiple policies in dir) |
| Override input values | inputs { key = value } | Per test file — uses policy default if omitted |
| Expected failure | expect_failure = true | All policies |
| Skip evaluation | skip = true | Resources only |
| Cross-resource ref | resource_type.name.attrs.attribute | Same file only |
| Omitted attributes | Don’t specify in attrs = {} | Causes error unless policy uses core::try() |
Problem: Need to enforce that every S3 bucket has a corresponding encryption configuration.
Solution: Evaluate buckets, look up encryption configs via core::getresources().
# Top-level: Get all encryption configs once
locals {
all_encryption_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {})
}
# Resource-level: Find matching config for each bucket
resource_policy "aws_s3_bucket" "require_encryption" {
locals {
matching_configs = [
for config in local.all_encryption_configs :
config if config.bucket == attrs.bucket
]
}
enforce {
condition = core::try(local.matching_configs[0], null) != null
error_message = "Bucket must have encryption config"
}
}Test Structure:
# Evaluated resource - NO skip
resource "aws_s3_bucket" "test" {
attrs = { bucket = "test" }
}
# Looked-up resource - YES skip (but still visible to core::getresources)
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
skip = true
attrs = {
bucket = aws_s3_bucket.test.bucket
rule = [{ ... }] # Must be array!
}
}Key Points:
skip = true ARE visible to core::getresources()locals for core::getresources() (performance)Problem: Need to check two related attributes to determine compliance.
Example: S3 must use customer-managed KMS keys (not AWS-managed or AES256).
AWS encryption types:
Why one check fails:
# ❌ Only checks algorithm
condition = attrs.sse_algorithm == "aws:kms"
# PASSES even without kms_master_key_id (uses AWS-managed key!)
# ❌ Only checks key ID
condition = attrs.kms_master_key_id != ""
# PASSES even with "AES256" algorithm (not using KMS!)Correct: Check both
locals {
sse_algorithm = core::try(attrs.encryption[0].sse_algorithm, "")
kms_key_id = core::try(attrs.encryption[0].kms_master_key_id, "")
}
enforce {
condition = local.sse_algorithm == "aws:kms" && local.kms_key_id != ""
error_message = "Must use customer-managed KMS. Found algorithm: '${local.sse_algorithm}', key specified: ${local.kms_key_id != ""}"
}Discovery: With two-policy approach (one policy for buckets, another for encryption configs), test files fail when they contain 5+ buckets.
Workaround 1: Use single-policy approach (no limit observed)
# ✅ One policy evaluates buckets, looks up configs
resource_policy "aws_s3_bucket" "require_encryption" {
# Can test 6+ buckets in single file
}Workaround 2: Split tests across multiple files (max 4 buckets each)
tests/
├── test-scenario-1.policytest.hcl # 4 buckets
├── test-scenario-2.policytest.hcl # 4 buckets
└── test-scenario-3.policytest.hcl # 4 bucketsMistake: Wrong resource has skip or expect_failure
# ❌ WRONG - Policy evaluates buckets but test skips them
resource "aws_s3_bucket" "test" {
skip = true # Policy can't evaluate this!
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
expect_failure = true # Policy doesn't evaluate this!
}
# ✅ CORRECT - Match policy evaluation target
resource "aws_s3_bucket" "test" {
expect_failure = true # Policy evaluates buckets
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
skip = true # Policy looks this up via core::getresources()
}Mistake: Using objects instead of arrays
# ❌ WRONG
attrs = {
rule = { key = "value" } # Object
}
# ✅ CORRECT
attrs = {
rule = [{ key = "value" }] # Array
}Reason: Terraform resources use arrays. Policies access attrs.rule[0].
These are behaviors where tfpolicy test passes silently but a real terraform plan --policies= run fails or behaves differently. Always verify port-range and integer-arithmetic policies against a real plan.
core::range(start, end) works correctly when called with hardcoded integer literals. However, when start or end come from mocked attrs.* integer values (e.g. attrs.from_port, attrs.to_port), the policytest framework treats those values as unknown/unevaluated at test time and core::range() silently returns an empty list [].
Impact: A policy that uses core::range() with dynamic port attributes will appear to pass all tests — including expect_failure cases — because the range is always empty. The bug only surfaces against a real plan.
⚠️
core::alltrue()andcore::anytrue()do NOT exist in tfpolicy runtime. Using them will produceError: Call to unknown function / There is no function named "alltrue" in namespace core::.. The examples below show the problem pattern (❌) and the correct alternative (✅).
# ❌ WRONG — core::range() + core::alltrue() — both problematic
locals {
ports_in_range = core::range(core::try(attrs.from_port, 0), core::try(attrs.to_port, 0) + 1)
# ↑ returns [] in policytest because attrs.from_port/to_port are unknown at test time
all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)])
# ↑ core::alltrue does NOT exist — will error; also core::range() returns [] here
}Fix: Use the count approach instead — it works correctly with dynamic attrs.* values in both policytest and real plans:
# ✅ Count approach — consistent in policytest and real plan evaluation
locals {
authorized_ports = [80, 443]
from_port = core::try(attrs.from_port, 0)
to_port = core::try(attrs.to_port, 0)
authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port]
all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1)
}See verified-syntax.md Mistake 23 for the full pattern.
In tfpolicy test, when a policy calls core::getresources("some_type", filter), the lookup searches all mock resources of that type in the entire test file — including resources marked expect_failure = true and resources marked skip = true.
Impact: A test scenario that requires core::getresources() to return zero results (or no compliant results) will silently produce the wrong outcome if any other scenario in the same file defines a resource of that type that satisfies the filter.
# ❌ PROBLEMATIC — both scenarios in the same test file
# "fail_no_defaults" incorrectly passes because core::getresources() picks up
# the compliant_defaults resource from the other scenario.
resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" {
skip = true # skip = true is still visible to core::getresources()!
attrs = { http_tokens = "required" }
}
resource "aws_instance" "pass_with_defaults" {
attrs = { instance_type = "t3.micro" }
}
resource "aws_instance" "fail_no_defaults" {
expect_failure = true
attrs = { instance_type = "t3.micro" }
# WRONG: core::getresources("aws_ec2_instance_metadata_defaults", ...) still sees
# "compliant_defaults" above → policy evaluates as compliant → expect_failure passes incorrectly.
}Fix: Place scenarios with conflicting core::getresources() context into separate .policytest.hcl files. Each file is an independent resource graph.
# ✅ File 1: test-with-compliant-defaults.policytest.hcl
resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" {
skip = true
attrs = { http_tokens = "required" }
}
resource "aws_instance" "pass_with_defaults" {
attrs = { instance_type = "t3.micro" }
}
# ✅ File 2: test-no-defaults.policytest.hcl
# No aws_ec2_instance_metadata_defaults defined — core::getresources() returns empty list.
resource "aws_instance" "fail_no_defaults" {
expect_failure = true
attrs = { instance_type = "t3.micro" }
}Rule: Whenever a test scenario relies on core::getresources() returning zero results (or no compliant results) for a given type, that scenario must be in its own .policytest.hcl file, completely isolated from any scenario that defines resources of that same type.
This file