diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index 9771acb..eac6b43 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -59,6 +59,46 @@ func TestDiffMarksReadOnlyForNSSOA(t *testing.T) { } } +// 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