0b26923586
RecordDiff.Key() gives a stable normalized identifier ("TYPE name.") for
every diff kind, exposed as recordView.Key. ApplyRequest now takes
Updates/Prunes key lists instead of two booleans, so callers can apply a
subset of records. service.Apply builds the applied set with selected
prunes (Delete) added before selected updates (Add/Update) — an
invariant, not an option — since the provider rejects an Add/Update
whose name still conflicts with an existing record (e.g. a CNAME cannot
be created while an A on the same name still exists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
245 lines
9.1 KiB
Go
245 lines
9.1 KiB
Go
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
|
||
}
|
||
|
||
// TestRecordDiffKeyNormalizes pins RecordDiff.Key() down to the same
|
||
// normalization as model.Record.Key() (lowercase + trailing dot), for a
|
||
// Delete diff (which has no Desired, only Type/Name populated directly).
|
||
func TestRecordDiffKeyNormalizes(t *testing.T) {
|
||
d := RecordDiff{Kind: Delete, Type: model.A, Name: "Mail.Example.COM"}
|
||
if got := d.Key(); got != "A mail.example.com." {
|
||
t.Fatalf("key: %q", got)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|