Files
dns-autoresolver/internal/diff/diff_test.go
T

235 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}