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") } }