496f258cb0
The capture carried a production certname, real role and class names and internal paths into a public repo. - replace hostnames, class names and paths with fake equivalents - keep the shape under test: catalog envelope, string version, null code_id, resources with and without parameters and file/line, the exported and sensitive_parameters cases - document the range of --retry and --timeout
389 lines
14 KiB
Go
389 lines
14 KiB
Go
package catalog
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func mustCatalog(t *testing.T, body string) *Catalog {
|
|
t.Helper()
|
|
var c Catalog
|
|
if err := json.Unmarshal([]byte(body), &c); err != nil {
|
|
t.Fatalf("parsing catalog fixture: %v", err)
|
|
}
|
|
return &c
|
|
}
|
|
|
|
const baseCatalog = `{
|
|
"name":"host.example.net","environment":"develop","version":1,
|
|
"classes":["settings","motd"],
|
|
"resources":[
|
|
{"type":"Stage","title":"main","parameters":{"name":"main"}},
|
|
{"type":"Class","title":"Motd","parameters":{"content":"old"}},
|
|
{"type":"File","title":"/etc/motd","file":"site.pp","line":10,"tags":["file","z","a"],"parameters":{"ensure":"file","content":"old","loglevel":"notice","_meta":"x","require":"Package[vim]","notify":["Service[sshd]","Service[chronyd]"]}},
|
|
{"type":"Service","title":"sshd","file":"ssh.pp","line":4,"tags":["service"],"parameters":{"ensure":"running","enable":true,"port":28}},
|
|
{"type":"Notify","title":"gone","file":"old.pp","line":1,"parameters":{"message":"bye"}},
|
|
{"type":"Package","title":"vim","parameters":{"ensure":"installed"}},
|
|
{"type":"Exec","title":"reload","parameters":{"command":"/bin/true","environment":{"HOME":"/root","PATH":"/bin"}}},
|
|
{"type":"Nagios_service","title":"exported","exported":true,"parameters":{"check":"x"}},
|
|
{"type":"Mysql::Db","title":"app","sensitive_parameters":["password"],"parameters":{"user":"app","password":"hunter2"}}
|
|
]
|
|
}`
|
|
|
|
const testCatalog = `{
|
|
"name":"host.example.net","environment":"benvin_test","version":2,
|
|
"classes":["settings","motd"],
|
|
"resources":[
|
|
{"type":"Stage","title":"main","parameters":{"name":"main"}},
|
|
{"type":"Class","title":"Motd","parameters":{"content":"new"}},
|
|
{"type":"File","title":"motd-managed","file":"site.pp","line":19,"tags":["a","file","z"],"parameters":{"path":"/etc/motd","ensure":"file","content":"new","loglevel":"debug","stage":"main","_meta":"y","require":"Package[vim2]","notify":["Service[chronyd]","Service[sshd]"]}},
|
|
{"type":"Service","title":"sshd","file":"ssh.pp","line":4,"tags":["service"],"parameters":{"ensure":"running","enable":true,"port":"28"}},
|
|
{"type":"Notify","title":"added","file":"new.pp","line":3,"parameters":{"message":"hi"}},
|
|
{"type":"Package","title":"vim","parameters":{"ensure":"installed","provider":"dnf"}},
|
|
{"type":"Exec","title":"reload","parameters":{"command":"/bin/true","environment":{"HOME":"/root","PATH":"/usr/bin"}}},
|
|
{"type":"Nagios_service","title":"exported","exported":true,"parameters":{"check":"y"}},
|
|
{"type":"Mysql::Db","title":"app","sensitive_parameters":["password"],"parameters":{"user":"app","password":"hunter3"}}
|
|
]
|
|
}`
|
|
|
|
func normalized(t *testing.T, body string, opts Options) map[string]Resource {
|
|
t.Helper()
|
|
return Normalize(mustCatalog(t, body), opts)
|
|
}
|
|
|
|
func changeSet(t *testing.T) []Change {
|
|
t.Helper()
|
|
return Diff(
|
|
normalized(t, baseCatalog, Options{}),
|
|
normalized(t, testCatalog, Options{}),
|
|
DiffOptions{},
|
|
)
|
|
}
|
|
|
|
func intPtr(n int) *int { return &n }
|
|
|
|
func byKey(changes []Change) map[string][]Change {
|
|
out := map[string][]Change{}
|
|
for _, c := range changes {
|
|
out[c.Key()] = append(out[c.Key()], c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestNormalizeDropsNoiseParameters(t *testing.T) {
|
|
res := normalized(t, testCatalog, Options{})
|
|
motd, ok := res["File[/etc/motd]"]
|
|
if !ok {
|
|
t.Fatalf("File not re-keyed by its path parameter: %v", res)
|
|
}
|
|
for _, drop := range []string{"loglevel", "stage", "_meta", "before", "require", "path"} {
|
|
if _, present := motd.Parameters[drop]; present {
|
|
t.Errorf("parameter %q should have been dropped", drop)
|
|
}
|
|
}
|
|
if motd.Parameters["content"] != "new" {
|
|
t.Errorf("real parameters dropped: %+v", motd.Parameters)
|
|
}
|
|
if motd.File != "site.pp" || motd.Line == nil || *motd.Line != 19 {
|
|
t.Errorf("file/line context lost: %+v", motd)
|
|
}
|
|
if got := motd.Tags; len(got) != 3 || got[0] != "a" || got[2] != "z" {
|
|
t.Errorf("tags not sorted: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSkipsClassesAndExported(t *testing.T) {
|
|
res := normalized(t, testCatalog, Options{})
|
|
if _, ok := res["Class[Motd]"]; ok {
|
|
t.Error("Class resources must be ignored by default")
|
|
}
|
|
if _, ok := res["Nagios_service[exported]"]; ok {
|
|
t.Error("exported resources must be skipped")
|
|
}
|
|
|
|
withClasses := normalized(t, testCatalog, Options{IncludeClasses: true})
|
|
if _, ok := withClasses["Class[Motd]"]; !ok {
|
|
t.Error("--include-classes must keep Class resources")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeRedactsSensitiveParameters(t *testing.T) {
|
|
base := normalized(t, baseCatalog, Options{})["Mysql::Db[app]"]
|
|
test := normalized(t, testCatalog, Options{})["Mysql::Db[app]"]
|
|
|
|
for _, r := range []Resource{base, test} {
|
|
value, _ := r.Parameters["password"].(string)
|
|
if value == "hunter2" || value == "hunter3" {
|
|
t.Fatalf("sensitive value leaked: %q", value)
|
|
}
|
|
if len(value) < len("redacted:sha256:") || value[:16] != "redacted:sha256:" {
|
|
t.Fatalf("unexpected redaction %q", value)
|
|
}
|
|
}
|
|
if base.Parameters["password"] == test.Parameters["password"] {
|
|
t.Error("a changed secret must still show as a change")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeIgnoreFlags(t *testing.T) {
|
|
res := normalized(t, testCatalog, Options{
|
|
IgnoreResources: []string{"Notify[*]", "Package[vim]"},
|
|
IgnoreAttrs: []string{"ensure"},
|
|
})
|
|
if _, ok := res["Notify[added]"]; ok {
|
|
t.Error("--ignore glob did not drop Notify[added]")
|
|
}
|
|
if _, ok := res["Package[vim]"]; ok {
|
|
t.Error("--ignore did not drop Package[vim]")
|
|
}
|
|
if _, ok := res["File[/etc/motd]"].Parameters["ensure"]; ok {
|
|
t.Error("--ignore-attr did not drop the ensure parameter")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeRelationshipParameters(t *testing.T) {
|
|
scalar := Normalize(&Catalog{Resources: []Resource{
|
|
{Type: "Service", Title: "a", Parameters: map[string]any{"notify": "Service[b]"}},
|
|
}}, Options{})
|
|
list := Normalize(&Catalog{Resources: []Resource{
|
|
{Type: "Service", Title: "a", Parameters: map[string]any{"notify": []any{"Service[b]"}}},
|
|
}}, Options{})
|
|
|
|
if changes := Diff(scalar, list, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("scalar and single-element array must compare equal: %+v", changes)
|
|
}
|
|
|
|
unsorted := Normalize(&Catalog{Resources: []Resource{
|
|
{Type: "Service", Title: "a", Parameters: map[string]any{"subscribe": []any{"File[y]", "File[x]"}}},
|
|
}}, Options{})
|
|
sorted := Normalize(&Catalog{Resources: []Resource{
|
|
{Type: "Service", Title: "a", Parameters: map[string]any{"subscribe": []any{"File[x]", "File[y]"}}},
|
|
}}, Options{})
|
|
if changes := Diff(unsorted, sorted, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("relationship array order must not matter: %+v", changes)
|
|
}
|
|
}
|
|
|
|
func TestDiffResourceAddedRemoved(t *testing.T) {
|
|
changes := byKey(changeSet(t))
|
|
|
|
added := changes["Notify[added]"]
|
|
if len(added) != 1 || added[0].DiffType != DiffAdded {
|
|
t.Fatalf("added resource not detected: %+v", added)
|
|
}
|
|
if added[0].NewValue == nil || added[0].OldValue != nil {
|
|
t.Errorf("added resource must carry only new_value: %+v", added[0])
|
|
}
|
|
|
|
removed := changes["Notify[gone]"]
|
|
if len(removed) != 1 || removed[0].DiffType != DiffRemoved {
|
|
t.Fatalf("removed resource not detected: %+v", removed)
|
|
}
|
|
if removed[0].OldValue == nil || removed[0].NewValue != nil {
|
|
t.Errorf("removed resource must carry only old_value: %+v", removed[0])
|
|
}
|
|
}
|
|
|
|
func TestDiffAttributeAndElementChanges(t *testing.T) {
|
|
changes := byKey(changeSet(t))
|
|
|
|
motd := changes["File[/etc/motd]"]
|
|
if len(motd) != 1 {
|
|
t.Fatalf("expected one change on the motd file, got %+v", motd)
|
|
}
|
|
if motd[0].DiffType != DiffChanged {
|
|
t.Errorf("attribute change must be %q: %+v", DiffChanged, motd[0])
|
|
}
|
|
if got := motd[0].Structure; len(got) != 2 || got[0] != "parameters" || got[1] != "content" {
|
|
t.Errorf("unexpected structure %v", got)
|
|
}
|
|
if motd[0].OldValue != "old" || motd[0].NewValue != "new" {
|
|
t.Errorf("unexpected values: %+v", motd[0])
|
|
}
|
|
|
|
exec := changes["Exec[reload]"]
|
|
if len(exec) != 1 || exec[0].DiffType != DiffElemented {
|
|
t.Fatalf("nested change must be %q: %+v", DiffElemented, exec)
|
|
}
|
|
if got := exec[0].Structure; len(got) != 3 || got[2] != "PATH" {
|
|
t.Errorf("nested structure not reported: %v", got)
|
|
}
|
|
if exec[0].OldValue != "/bin" || exec[0].NewValue != "/usr/bin" {
|
|
t.Errorf("nested values wrong: %+v", exec[0])
|
|
}
|
|
}
|
|
|
|
func TestDiffSuppressesDatatypeChanges(t *testing.T) {
|
|
if changes := byKey(changeSet(t))["Service[sshd]"]; len(changes) != 0 {
|
|
t.Errorf("28 vs \"28\" must be suppressed by default: %+v", changes)
|
|
}
|
|
|
|
shown := Diff(
|
|
normalized(t, baseCatalog, Options{}),
|
|
normalized(t, testCatalog, Options{}),
|
|
DiffOptions{DisplayDatatypeChanges: true},
|
|
)
|
|
sshd := byKey(shown)["Service[sshd]"]
|
|
if len(sshd) != 1 || !sshd[0].Datatype {
|
|
t.Fatalf("--display-datatype-changes must surface the change: %+v", sshd)
|
|
}
|
|
}
|
|
|
|
func TestDiffIgnoresFileLineMoves(t *testing.T) {
|
|
base := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", File: "a.pp", Line: intPtr(1),
|
|
Parameters: map[string]any{"ensure": "file"}}}
|
|
test := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", File: "b.pp", Line: intPtr(99),
|
|
Parameters: map[string]any{"ensure": "file"}}}
|
|
if changes := Diff(base, test, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("a resource moving file/line is not a change: %+v", changes)
|
|
}
|
|
}
|
|
|
|
func TestDiffSeverity(t *testing.T) {
|
|
for _, c := range changeSet(t) {
|
|
want := SeverityNormal
|
|
if IsCritical(c.Type) {
|
|
want = SeverityCritical
|
|
}
|
|
if c.Severity != want {
|
|
t.Errorf("%s severity = %q, want %q", c.Key(), c.Severity, want)
|
|
}
|
|
}
|
|
if !IsCritical("Systemd::Unit_file") || !IsCritical("Docker::Container") {
|
|
t.Error("critical type list incomplete")
|
|
}
|
|
if IsCritical("Notify") {
|
|
t.Error("Notify must not be critical")
|
|
}
|
|
}
|
|
|
|
func TestDiffDeterministicOrdering(t *testing.T) {
|
|
first := changeSet(t)
|
|
for i := 0; i < 20; i++ {
|
|
next := changeSet(t)
|
|
if len(first) != len(next) {
|
|
t.Fatalf("change count varies: %d vs %d", len(first), len(next))
|
|
}
|
|
for j := range first {
|
|
if first[j].Key() != next[j].Key() || structureKey(first[j].Structure) != structureKey(next[j].Structure) {
|
|
t.Fatalf("ordering is not stable at %d: %+v vs %+v", j, first[j], next[j])
|
|
}
|
|
}
|
|
}
|
|
for i := 1; i < len(first); i++ {
|
|
if first[i-1].Key() > first[i].Key() {
|
|
t.Fatalf("changes not sorted by resource: %q before %q", first[i-1].Key(), first[i].Key())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiffIgnoresMapOrdering(t *testing.T) {
|
|
base := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", Parameters: map[string]any{
|
|
"opts": map[string]any{"b": 1, "a": 2},
|
|
}}}
|
|
test := map[string]Resource{"File[/a]": {Type: "File", Title: "/a", Parameters: map[string]any{
|
|
"opts": map[string]any{"a": 2, "b": 1},
|
|
}}}
|
|
if changes := Diff(base, test, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("map key ordering reported as a change: %+v", changes)
|
|
}
|
|
}
|
|
|
|
func TestSummarise(t *testing.T) {
|
|
s := Summarise(changeSet(t))
|
|
if s.Added != 1 || s.Removed != 1 {
|
|
t.Errorf("unexpected summary: %+v", s)
|
|
}
|
|
if s.Changed != 4 {
|
|
t.Errorf("changed = %d, want 4 (motd content, vim provider, exec PATH, db password)", s.Changed)
|
|
}
|
|
if s.Critical != 3 {
|
|
t.Errorf("critical = %d, want 3 (File, Package, Exec)", s.Critical)
|
|
}
|
|
}
|
|
|
|
func TestDiffNoChanges(t *testing.T) {
|
|
norm := normalized(t, baseCatalog, Options{})
|
|
if changes := Diff(norm, norm, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("identical catalogs produced changes: %+v", changes)
|
|
}
|
|
}
|
|
|
|
// Resources shaped like an OpenVoxServer 8.8 catalog. Class, stage and anchor
|
|
// resources carry no "parameters" key, and resources with no manifest location
|
|
// carry neither "file" nor "line".
|
|
const optionalFieldCatalog = `{
|
|
"name":"web01.example.test","environment":"develop","version":"0123abc",
|
|
"resources":[
|
|
{"type":"Class","title":"Settings","tags":["class","settings"],"exported":false,"kind":"unknown"},
|
|
{"type":"Anchor","title":"examplemod::end","tags":["anchor","examplemod::end"],"file":"init.pp","line":111,"exported":false,"kind":"compilable_type"},
|
|
{"type":"File","title":"/opt/puppetlabs/puppet/cache/examplemod/modules/example_local.loaded","tags":["file"],"file":"module.pp","line":177,"exported":false,"kind":"compilable_type"},
|
|
{"type":"Stage","title":"main","tags":["stage"],"exported":false,"kind":"compilable_type","parameters":{"name":"main"}}
|
|
]
|
|
}`
|
|
|
|
func TestNormalizeAcceptsMissingParameters(t *testing.T) {
|
|
res := normalized(t, optionalFieldCatalog, Options{IncludeClasses: true})
|
|
|
|
for _, key := range []string{"Class[Settings]", "Anchor[examplemod::end]"} {
|
|
r, ok := res[key]
|
|
if !ok {
|
|
t.Fatalf("%s dropped: %v", key, res)
|
|
}
|
|
if r.Parameters == nil {
|
|
t.Errorf("%s: absent parameters must normalise to an empty map", key)
|
|
}
|
|
if len(r.Parameters) != 0 {
|
|
t.Errorf("%s: unexpected parameters %v", key, r.Parameters)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiffTreatsMissingParametersAsEmpty(t *testing.T) {
|
|
absent := normalized(t, `{"resources":[{"type":"Anchor","title":"a","tags":["anchor"],"exported":false}]}`, Options{})
|
|
empty := normalized(t, `{"resources":[{"type":"Anchor","title":"a","tags":["anchor"],"exported":false,"parameters":{}}]}`, Options{})
|
|
|
|
if changes := Diff(absent, empty, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("absent and empty parameters must compare equal: %+v", changes)
|
|
}
|
|
if changes := Diff(empty, absent, DiffOptions{}); len(changes) != 0 {
|
|
t.Errorf("absent and empty parameters must compare equal in both directions: %+v", changes)
|
|
}
|
|
}
|
|
|
|
func TestDiffRemovedResourceWithoutParametersCarriesEmptyValue(t *testing.T) {
|
|
base := normalized(t, optionalFieldCatalog, Options{IncludeClasses: true})
|
|
changes := byKey(Diff(base, map[string]Resource{}, DiffOptions{}))
|
|
|
|
removed := changes["Class[Settings]"]
|
|
if len(removed) != 1 {
|
|
t.Fatalf("expected one removal, got %+v", removed)
|
|
}
|
|
params, ok := removed[0].OldValue.(map[string]any)
|
|
if !ok || len(params) != 0 {
|
|
t.Errorf("a resource without parameters must report an empty set, got %#v", removed[0].OldValue)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKeepsMissingSourceDistinctFromLineZero(t *testing.T) {
|
|
res := normalized(t, optionalFieldCatalog, Options{IncludeClasses: true})
|
|
|
|
settings := res["Class[Settings]"]
|
|
if settings.HasSource() {
|
|
t.Errorf("a resource with no file/line must report no source: %+v", settings)
|
|
}
|
|
if settings.Line != nil {
|
|
t.Errorf("an absent line must stay absent, got %d", *settings.Line)
|
|
}
|
|
|
|
anchor := res["Anchor[examplemod::end]"]
|
|
if !anchor.HasSource() || anchor.File != "init.pp" || *anchor.Line != 111 {
|
|
t.Errorf("manifest location lost: %+v", anchor)
|
|
}
|
|
|
|
zero := Resource{Type: "File", Title: "/a", File: "site.pp", Line: intPtr(0)}
|
|
if !zero.HasSource() {
|
|
t.Error("line 0 with a file is a real location")
|
|
}
|
|
}
|