Files
unkin-agent ae948c7f04
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
track the opening quote and scan interpolations as code
A differently quoted substring inside a ${...} interpolation ended the
outer string early and truncated the argument list, dropping every name
after it. One shared walker now backs every source scan.

Lowercase the recovered prefix of an unresolved reference, and every
class name as it is added, so a mixed-case interpolated name still
widens.

Report fact records with no certname instead of dropping them, and
assert stripSource keeps newline positions rather than just length.
2026-09-12 17:54:03 +10:00

356 lines
12 KiB
Go

package impact
import (
"fmt"
"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"
if err := sameNewlines(body, stripSource(body)); err != nil {
t.Fatal(err)
}
}
// sameNewlines is the invariant stripSource owes the statement scanner: every
// byte keeps its offset, and a newline stays a newline and nothing else
// becomes one.
func sameNewlines(body, stripped string) error {
if len(stripped) != len(body) {
return fmt.Errorf("stripSource changed length: %d -> %d", len(body), len(stripped))
}
for i := range body {
if (body[i] == '\n') != (stripped[i] == '\n') {
return fmt.Errorf("newline at %d moved: %q -> %q", i, body[i], stripped[i])
}
}
return nil
}
func TestScanQuoteStateIsDelimiterAware(t *testing.T) {
tests := []struct {
name string
body string
want []string
unresolved int
}{
{
// A single-quoted string inside a ${...} interpolation may hold the
// outer delimiter. Toggling on any quote ends the argument list at
// the `)` inside it and loses everything after it.
name: "double quoted delimiter inside an interpolated single quoted string",
body: "class p {\n include(\"${sprintf('a\"a)b\"c', $x)}\", profile::real)\n}\n",
want: []string{"profile::real"},
unresolved: 1,
},
{
name: "single quoted string holding a double quote",
body: "class p {\n include('it\"s', profile::real)\n}\n",
want: []string{"profile::real"}, unresolved: 1,
},
{
name: "double quoted string holding a single quote",
body: "class p {\n include(\"a'b\", profile::real)\n}\n",
want: []string{"profile::real"}, unresolved: 1,
},
{
name: "interpolation holding a bracket and a quote",
body: "class p {\n notify { \"${facts['a[b']}\": }\n include profile::real\n}\n",
want: []string{"profile::real"},
},
{
name: "nested interpolation",
body: "class p {\n $t = \"${a[\"${b['c}']}\"]}\"\n include profile::real\n}\n",
want: []string{"profile::real"},
},
{
name: "escaped quote does not end the string",
body: "class p {\n include(\"a\\\")b\", profile::real)\n}\n",
want: []string{"profile::real"}, unresolved: 1,
},
{
name: "hash inside an interpolated single quoted string is not a comment",
body: "class p {\n $t = \"${a['#']}\"\n include profile::real\n}\n",
want: []string{"profile::real"},
},
{
// The comment scan shares the string skipper, so a `#` inside a
// nested double-quoted string must not blank the rest of the line.
name: "hash inside an interpolated double quoted string is not a comment",
body: "class p {\n $t = \"${a[\"#\"]}\" include profile::real\n}\n",
want: []string{"profile::real"},
},
{
name: "selector arm quoted with the other delimiter",
body: "class p {\n include $x ? { \"it's\" => 'profile::a', default => 'profile::b' }\n}\n",
want: []string{"profile::a", "profile::b"},
},
{
name: "class resource title list with an awkward quote",
body: "class p {\n class { ['profile::a', \"it's\"]: }\n}\n",
want: []string{"profile::a"}, unresolved: 1,
},
{
name: "heredoc body holding an unbalanced quote",
body: "class p {\n $a = @(EOT)\n it's ) unbalanced \"\n EOT\n include profile::real\n}\n",
want: []string{"profile::real"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := sameNewlines(tc.body, stripSource(tc.body)); err != nil {
t.Fatal(err)
}
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) != tc.unresolved {
t.Errorf("unresolved = %#v, want %d", got, tc.unresolved)
}
})
}
}
func TestUnresolvedPrefixIsLowercased(t *testing.T) {
// Class names are case insensitive and the index holds them lowercase, so
// a mixed-case prefix that is not lowered widens to nothing.
body := "class p {\n Class[\"Mysql::${x}\"] -> Service['s']\n}\n"
got := unresolvedRefs(body)
want := UnresolvedRef{Form: "Class[]", Expr: `"Mysql::${x}"`, Prefix: "mysql::"}
if len(got) != 1 || got[0] != want {
t.Errorf("unresolved = %#v, want [%#v]", got, want)
}
}
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.Add("class p {\n include(\"${sprintf('a\"a)b\"c', $x)}\", profile::real)\n}\n")
f.Fuzz(func(t *testing.T, body string) {
stripped := stripSource(body)
if err := sameNewlines(body, stripped); err != nil {
t.Fatal(err)
}
scanReferences(stripped)
})
}