Files
unkin-agent 496f258cb0
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
make the catalog fixture synthetic
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
2026-09-12 17:19:28 +10:00

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