Files
puppet-catalog-tools/internal/impact/scan_test.go
T
unkin-agent 1c1df7f953 strip source quote-aware and resolve dynamic class references
- strip # comments with quote-state tracking so a hash in a string no
  longer truncates the line
- skip heredoc bodies in every @(EOT) form so documentation text is not
  indexed as a class reference
- take every literal name out of a selector, following a bare variable
  through the assignments in its own file
- record a class reference that stays unresolved against its file, with
  any literal prefix, instead of dropping it
- read a class name case insensitively in a title or type reference
- tag each edge as a declaration or an ordering reference
2026-09-12 17:18:56 +10:00

250 lines
7.8 KiB
Go

package impact
import (
"reflect"
"testing"
)
func TestScanSelectorArgumentKeepsEveryBranch(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "selector as the argument",
body: "class p {\n include $facts['role'] ? { 'web' => 'profile::web', default => 'profile::base' }\n}\n",
want: []string{"profile::base", "profile::web"},
},
{
name: "selector reached through a variable",
body: "class p {\n $profile = $facts['role'] ? { 'web' => 'profile::web', default => 'profile::base' }\n" +
" include $profile\n}\n",
want: []string{"profile::base", "profile::web"},
},
{
name: "selector inside a parenthesised call",
body: "class p {\n include(($x ? { default => 'profile::sel' }))\n}\n",
want: []string{"profile::sel"},
},
{
name: "selector arm holding an array",
body: "class p {\n include $x ? { 'a' => ['profile::a', 'profile::b'], default => 'profile::c' }\n}\n",
want: []string{"profile::a", "profile::b", "profile::c"},
},
{
name: "undef arm contributes nothing",
body: "class p {\n include $x ? { 'a' => 'profile::a', default => undef }\n}\n",
want: []string{"profile::a"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := refs(tc.body); !reflect.DeepEqual(got, tc.want) {
t.Errorf("references = %v, want %v", got, tc.want)
}
if got := unresolvedRefs(tc.body); len(got) != 0 {
t.Errorf("a fully resolved selector reported %v", got)
}
})
}
}
func TestScanHashInsideAStringIsNotAComment(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "double quoted",
body: "class p {\n $x = \"port #1\" ; include profile::afterhash\n}\n",
want: []string{"profile::afterhash"},
},
{
name: "single quoted",
body: "class p {\n $x = 'colour #fff' ; include profile::aftercolour\n}\n",
want: []string{"profile::aftercolour"},
},
{
name: "url fragment",
body: "class p {\n $u = 'https://h/p#frag'\n include profile::afterurl\n}\n",
want: []string{"profile::afterurl"},
},
{
name: "multi line string",
body: "class p {\n $s = \"#!/bin/sh\nexit 0\"\n include profile::afterscript\n}\n",
want: []string{"profile::afterscript"},
},
{
name: "a real comment is still stripped",
body: "class p {\n include profile::live # include profile::dead\n}\n",
want: []string{"profile::live"},
},
{
name: "a hash in a comment after a string",
body: "class p {\n $x = 'a' # ticket #42 include profile::dead\n include profile::live\n}\n",
want: []string{"profile::live"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := refs(tc.body); !reflect.DeepEqual(got, tc.want) {
t.Errorf("references = %v, want %v", got, tc.want)
}
})
}
}
func TestScanSkipsHeredocBodies(t *testing.T) {
body := "class p {\n" +
" $a = @(EOT)\n include profile::plain\n | EOT\n" +
" $b = @(\"EOT\")\n include profile::quoted\n |-EOT\n" +
" $c = @(EOT/L)\n include profile::escapes\n EOT\n" +
" $d = @(EOT:json/nt)\n include profile::syntax\n - EOT\n" +
" include profile::real\n}\n"
if got, want := refs(body), []string{"profile::real"}; !reflect.DeepEqual(got, want) {
t.Errorf("references = %v, want %v", got, want)
}
if got := unresolvedRefs(body); len(got) != 0 {
t.Errorf("heredoc scan reported %v", got)
}
}
func TestScanHeredocBodyDoesNotSwallowFollowingCode(t *testing.T) {
// An unbalanced brace and quote in the body must not leak into the scan.
body := "class p {\n $a = @(EOF)\n { \"unclosed ' ]]] \n EOF\n include profile::after\n}\n"
if got, want := refs(body), []string{"profile::after"}; !reflect.DeepEqual(got, want) {
t.Errorf("references = %v, want %v", got, want)
}
}
func TestScanReportsInterpolatedClassName(t *testing.T) {
body := "class p {\n include \"profile::role_${role}\"\n}\n"
if got := refs(body); len(got) != 0 {
t.Errorf("an interpolated name yielded %v, want nothing resolved", got)
}
got := unresolvedRefs(body)
want := []UnresolvedRef{{Form: "include", Expr: `"profile::role_${role}"`, Prefix: "profile::role_"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("unresolved = %#v, want %#v", got, want)
}
}
func TestScanReportsUnresolvableArguments(t *testing.T) {
tests := []struct {
name string
body string
want UnresolvedRef
}{
{
name: "bare variable",
body: "class p {\n include $unknown\n}\n",
want: UnresolvedRef{Form: "include", Expr: "$unknown"},
},
{
name: "variable assigned from a function",
body: "class p {\n $c = lookup('profile')\n include $c\n}\n",
want: UnresolvedRef{Form: "include", Expr: "lookup('profile')"},
},
{
name: "function call argument",
body: "class p {\n contain lookup('profile')\n}\n",
want: UnresolvedRef{Form: "contain", Expr: "lookup('profile')"},
},
{
name: "class resource with a computed title",
body: "class p {\n class { $title: }\n}\n",
want: UnresolvedRef{Form: "class {}", Expr: "$title"},
},
{
name: "interpolated class reference",
body: "class p {\n Class[\"profile::${x}\"] -> Service['s']\n}\n",
want: UnresolvedRef{Form: "Class[]", Expr: `"profile::${x}"`, Prefix: "profile::"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := unresolvedRefs(tc.body)
if len(got) != 1 || got[0] != tc.want {
t.Errorf("unresolved = %#v, want [%#v]", got, tc.want)
}
})
}
}
func TestScanCaseInsensitiveClassTitle(t *testing.T) {
// Class names are case insensitive in a resource title, so a mixed-case
// one must resolve rather than widen.
body := "class p {\n service { 's': require => Class['Mysql::server'] }\n}\n"
if got, want := refs(body), []string{"mysql::server"}; !reflect.DeepEqual(got, want) {
t.Errorf("references = %v, want %v", got, want)
}
if got := unresolvedRefs(body); len(got) != 0 {
t.Errorf("unresolved = %v", got)
}
}
func TestScanSurvivesMalformedInput(t *testing.T) {
bodies := []string{
"",
"class p {",
"class p {\n include [foo, bar\n",
"class p {\n include 'foo\n",
"class p {\n include \"foo::${\n",
"class p {\n include (\n",
"class p {\n Class[\n",
"class p {\n class { : }\n}\n",
"class p {\n include $x ? {\n",
"class p {\n include $x ? { 'a' => }\n}\n",
"class p {\n $a = @(EOT)\n no terminator here\n",
"@(",
"@()\n",
"#",
"$",
"))))]]]}}}",
"include " + "a::b, " + "include include include",
"class p {\n include [[a::b, [c::d, e::f]], g::h]\n}\n",
}
for _, body := range bodies {
names, unresolved := scanReferences(stripSource(body))
_, _ = names, unresolved
}
// The nested-array case must still resolve every name.
got := refs("class p {\n include [[a::b, [c::d, e::f]], g::h]\n}\n")
want := []string{"a::b", "c::d", "e::f", "g::h"}
if !reflect.DeepEqual(got, want) {
t.Errorf("nested arrays = %v, want %v", got, want)
}
}
func TestStripSourcePreservesLineCount(t *testing.T) {
// The statement scan reads newlines, so stripping must not move them.
body := "class p {\n $a = @(EOT)\n body\n EOT\n include foo # bar\n}\n"
stripped := stripSource(body)
if len(stripped) != len(body) {
t.Fatalf("stripped length %d, want %d", len(stripped), len(body))
}
for i := range body {
if body[i] == '\n' && stripped[i] != '\n' {
t.Fatalf("newline at %d was stripped", i)
}
}
}
func FuzzScanReferences(f *testing.F) {
f.Add("class p {\n include foo::bar\n}\n")
f.Add("class p {\n include $x ? { 'a' => 'b::c', default => undef }\n}\n")
f.Add("class p {\n $a = @(EOT)\n include x::y\n EOT\n}\n")
f.Add("class p {\n include \"a::${b}\" # '\n}\n")
f.Add("))))]]]}}}@(#'\"")
f.Fuzz(func(t *testing.T, body string) {
stripped := stripSource(body)
if len(stripped) != len(body) {
t.Fatalf("stripSource changed length: %d -> %d", len(body), len(stripped))
}
scanReferences(stripped)
})
}