package diff import ( "testing" "github.com/vasyakrg/dns-autoresolver/internal/model" ) func find(cs Changeset, key string) *RecordDiff { for i := range cs.Diffs { d := cs.Diffs[i] var r *model.Record if d.Desired != nil { r = d.Desired } else { r = d.Actual } if r.Key() == key { return &cs.Diffs[i] } } return nil } func TestDiffAddUpdateDeleteInSync(t *testing.T) { tmpl := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, // update {Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}, // add } actual := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}, {Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}}, // delete (extra) } cs := Diff(tmpl, actual) if d := find(cs, "A a.example.com."); d == nil || d.Kind != InSync { t.Fatalf("a should be InSync, got %+v", d) } if d := find(cs, "A b.example.com."); d == nil || d.Kind != Update { t.Fatalf("b should be Update, got %+v", d) } if d := find(cs, "A c.example.com."); d == nil || d.Kind != Add { t.Fatalf("c should be Add, got %+v", d) } if d := find(cs, "A d.example.com."); d == nil || d.Kind != Delete { t.Fatalf("d should be Delete, got %+v", d) } } func TestDiffMarksReadOnlyForNSSOA(t *testing.T) { tmpl := []model.Record{{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}} actual := []model.Record{{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns9.other.com."}}} cs := Diff(tmpl, actual) d := find(cs, "NS example.com.") if d == nil || d.Kind != Update || !d.ReadOnly { t.Fatalf("NS diff must be Update and ReadOnly, got %+v", d) } } // Global Constraint: an empty/nil template must not silently no-op — every managed // record in the zone must surface as a Delete, while read-only records (NS/SOA) // stay ReadOnly and excluded from Actionable(). This guards against mass deletion // bugs where a missing template accidentally wipes the zone unattended. func TestDiffEmptyTemplateDeletesAllManagedKeepsNSReadOnly(t *testing.T) { actual := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, {Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, } cs := Diff(nil, actual) if len(cs.Diffs) != 3 { t.Fatalf("expected 3 diffs (2 A deletes + 1 NS), got %d: %+v", len(cs.Diffs), cs.Diffs) } da := find(cs, "A a.example.com.") if da == nil || da.Kind != Delete || da.ReadOnly { t.Fatalf("A a.example.com. must be a non-read-only Delete, got %+v", da) } db := find(cs, "A b.example.com.") if db == nil || db.Kind != Delete || db.ReadOnly { t.Fatalf("A b.example.com. must be a non-read-only Delete, got %+v", db) } dns := find(cs, "NS example.com.") if dns == nil || dns.Kind != Delete || !dns.ReadOnly { t.Fatalf("NS example.com. must be a ReadOnly Delete, got %+v", dns) } act := cs.Actionable() if len(act) != 2 { t.Fatalf("expected 2 actionable deletes (A records only), got %d: %+v", len(act), act) } for _, d := range act { if d.Type == model.NS { t.Fatalf("NS must be excluded from Actionable(), got %+v", d) } } } func TestActionableExcludesInSyncAndReadOnly(t *testing.T) { tmpl := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, // add {Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, // read-only add } actual := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, } act := Diff(tmpl, actual).Actionable() if len(act) != 1 || act[0].Name != "b.example.com." { t.Fatalf("only b.example.com. is actionable, got %+v", act) } } // Prune-guard: with an empty/nil template every managed zone record surfaces // as a Delete. Prunes() must isolate exactly those (potentially destructive) // entries, while Updates() must stay empty — there is nothing safe to apply. func TestPrunesEmptyTemplateAllManagedNoneInUpdates(t *testing.T) { actual := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, {Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, } cs := Diff(nil, actual) prunes := cs.Prunes() if len(prunes) != 2 { t.Fatalf("expected 2 managed prunes (A records only), got %d: %+v", len(prunes), prunes) } for _, d := range prunes { if d.Kind != Delete { t.Fatalf("Prunes() must only contain Delete diffs, got %+v", d) } if d.ReadOnly { t.Fatalf("Prunes() must exclude ReadOnly diffs, got %+v", d) } if d.Type == model.NS { t.Fatalf("NS must be excluded from Prunes(), got %+v", d) } } updates := cs.Updates() if len(updates) != 0 { t.Fatalf("expected 0 updates for an empty template, got %d: %+v", len(updates), updates) } } // Mixed case: Add/Update land in Updates(), Delete lands in Prunes(), and the // ReadOnly NS diff (here an Add) is excluded from both — mirroring Actionable(). func TestUpdatesAndPrunesMixedSeparation(t *testing.T) { tmpl := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}, // update {Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}, // add {Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, // read-only add } actual := []model.Record{ {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, {Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}}, // delete (extra) } cs := Diff(tmpl, actual) updates := cs.Updates() if len(updates) != 2 { t.Fatalf("expected 2 updates (b update + c add), got %d: %+v", len(updates), updates) } for _, d := range updates { if d.Kind != Add && d.Kind != Update { t.Fatalf("Updates() must only contain Add/Update diffs, got %+v", d) } if d.ReadOnly { t.Fatalf("Updates() must exclude ReadOnly diffs, got %+v", d) } } prunes := cs.Prunes() if len(prunes) != 1 || prunes[0].Name != "d.example.com." { t.Fatalf("expected exactly 1 prune (d.example.com.), got %+v", prunes) } if prunes[0].ReadOnly { t.Fatalf("prune must not be ReadOnly, got %+v", prunes[0]) } // Updates ∪ Prunes must equal Actionable, with no overlap. act := cs.Actionable() if len(act) != len(updates)+len(prunes) { t.Fatalf("Updates()+Prunes() must partition Actionable(): actionable=%d updates=%d prunes=%d", len(act), len(updates), len(prunes)) } } // Dedup semantics: index() is keyed by model.Record.Key(). If the same Key() // appears twice within a single slice (e.g. actual records fetched from a // provider with a duplicate RRset entry), the map assignment in the loop // means the LAST occurrence wins and earlier ones are discarded silently. // This test fixes that behavior in place so it is a documented, intentional // choice rather than an accidental artifact of map iteration order. func TestIndexDedupLastWriteWins(t *testing.T) { actual := []model.Record{ {Type: model.A, Name: "dup.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "dup.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}, // same Key(), later wins } tmpl := []model.Record{ {Type: model.A, Name: "dup.example.com.", TTL: 300, Values: []string{"9.9.9.9"}}, } cs := Diff(tmpl, actual) // Only one diff should exist for the duplicated key (dedup collapsed it), // and it must reflect the LAST actual record ("9.9.9.9"), matching the // template value, hence InSync rather than Update. d := find(cs, "A dup.example.com.") if d == nil { t.Fatalf("expected a diff for dup.example.com.") } if d.Kind != InSync { t.Fatalf("expected InSync (last actual record wins in index()), got %+v", d) } if d.Actual == nil || len(d.Actual.Values) != 1 || d.Actual.Values[0] != "9.9.9.9" { t.Fatalf("expected Actual to be the last duplicate (9.9.9.9), got %+v", d.Actual) } count := 0 for _, dd := range cs.Diffs { if dd.Type == model.A && dd.Name == "dup.example.com." { count++ } } if count != 1 { t.Fatalf("expected exactly 1 diff for the duplicated key, got %d", count) } }