1c1df7f953
- 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
250 lines
7.8 KiB
Go
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)
|
|
})
|
|
}
|