package engine import ( "net/netip" "slices" "strings" "testing" "jarl/internal/model" ) // ptrs returns a reverse zone's PTR records in a view as "owner target", // with " disabled" when so. func (env *testEnv) ptrs(view, zone string) []string { env.t.Helper() s := env.state() z := s.FindZone(zone, s.ViewByName(view).ID) if z == nil { env.t.Fatalf("no zone %s in %s", zone, view) } var out []string for _, r := range s.ZoneRecords(z.ID) { if r.Type == "PTR" { d := r.Name + " " + r.Data if r.Disabled { d += " disabled" } out = append(out, d) } } return out } func TestReverseFillAndSuggest(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, recIn("internal", "@", "A", "192.0.2.25"), recIn("internal", "nas", "A", "10.0.10.20"), recIn("internal", "files", "A", "10.0.10.20"), recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"), recIn("internal", "*.dev", "A", "10.0.10.99"), recIn("internal", "lo", "A", "127.0.0.1")) sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) var got []string for _, s := range sugg { got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone) } want(t, "suggestions", got, "yours internal 10.0.10.0/24 10.0.10.in-addr.arpa", "yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa", "public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa", "public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa") if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" { t.Errorf("10.0.10.0/24: %+v", sugg[0]) } // No view given: the private view whose records use the addresses. res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true})) want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.") if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) { t.Errorf("warnings %q", res.Warnings) } // The mail server's name beats the domain's own. env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true})) want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.") // Filling an existing zone; then there is nothing left to add. env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")})) st := env.state() z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID) env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})) if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") { t.Errorf("ip6 fill: %q", p) } if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") { t.Errorf("second fill: %v", e) } got = got[:0] for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) { got = append(got, s.Prefix) } want(t, "suggestions left", got, "192.0.2.10/32") // Addresses that two private views use: the view must be given. env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}), NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")})) if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") { t.Errorf("two views: %v", e) } } func TestAutoPTR(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")})) st := env.state() zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)})) rz := "10.0.10.in-addr.arpa." res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5")) want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.") if len(res.Warnings) > 0 { t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings) } // A second name for the address: its PTR stays. res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30")) want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.") if len(res.Warnings) > 0 { t.Errorf("warnings %q", res.Warnings) } env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil)) // Moved, disabled, enabled, deleted. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")})) want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.") env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)})) want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled") env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)})) want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.") env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil)) want(t, "deleted", env.ptrs("internal", rz)) // A whole set replaced. env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}})) want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.") env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}})) want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.") // Deleting by name and type. env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")})) want(t, "deleted by match", env.ptrs("internal", rz)) // Off again: records come and go alone. env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)})) env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50")) want(t, "off", env.ptrs("internal", rz)) } func suggested(env *testEnv, local ...netip.Addr) []string { var out []string for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) { out = append(out, s.Group+" "+s.View+" "+s.Prefix) } return out } // Public IPv6: the /48 only when this server has an address in it, else // the address alone, as a public suggestion. Dismissed networks and views // with suggestions off get none. func TestReverseSuggestionGroups(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"), recIn("internal", "gh", "AAAA", "2606:50c0:8000::153")) want(t, "no local addresses", suggested(env), "public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128") want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")), "yours internal 2a02:8013:7f3d::/48", "public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128") env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"})) if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") { t.Errorf("dismissed still suggested: %q", got) } if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict { t.Errorf("undismiss of one not dismissed: %v", e) } env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"})) // Several at once. env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}})) if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) { t.Errorf("dismissed together, still suggested: %q", got) } env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}})) if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") { t.Errorf("undismissed not suggested: %q", got) } // A second private view using the same address (a VPS's view): with // its suggestions off it gets none, and doesn't count when choosing // the view of a new reverse zone. env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"})) if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") { t.Errorf("vps view: %q", got) } if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") { t.Errorf("two views: %v", e) } vps := env.state().ViewByName("vps") env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)})) if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) { t.Errorf("vps view with suggestions off: %q", got) } env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true})) want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.") } // A zone created inside another takes the parent's records under it. func TestChildZoneTakesRecords(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")})) ptr := func(name, data string) Request { return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data}) } env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com.")) env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30")) res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true})) want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.") want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.") for _, p := range res.Problems { if p.Code == "shadowed_by_zone" { t.Errorf("still hidden records: %v", p) } } // A forward child: its delegation and glue stay in the parent. env.submit(env.admin, recIn("internal", "lab", "NS", "ns.lab.example.com."), recIn("internal", "ns.lab", "A", "10.0.30.1"), recIn("internal", "www.lab", "A", "10.0.30.2")) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")})) st := env.state() lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID) var labRecs []string for _, r := range st.ZoneRecords(lab.ID) { if r.Type != "NS" { labRecs = append(labRecs, r.Name+" "+r.Type) } } want(t, "lab", labRecs, "www.lab.example.com. A") v := env.view("internal") // without NS records if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) { t.Errorf("parent after: %q", v) } if env.recordID("internal", "lab", "NS") == 0 { t.Error("the delegation left the parent") } } // With All, the networks no suggestion is made for unasked are listed too, // marked: views with suggestions off, dismissed ones, public views. func TestReverseCandidatesAll(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)})) env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})) internal := env.state().ViewByName("internal") env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)})) list := func(opt ReverseOptions) []string { var out []string for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) { out = append(out, s.View+" "+s.Prefix+" "+s.Hidden) } return out } want(t, "unasked", list(ReverseOptions{})) want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}), "internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off") got := list(ReverseOptions{All: true, View: "external"}) want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view") env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)})) want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}), "internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ") } // An address only another view has a record for: a reverse zone in the // internal view is offered for it, and filled from that view's record. func TestReverseFromOtherView(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)})) var got []string for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) { got = append(got, s.Prefix+" "+s.Hidden) } if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") { t.Errorf("internal candidates %q", got) } // Not suggested unasked: the internal view's own records don't use it. for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) { if strings.HasPrefix(s.Prefix, "2600:") { t.Errorf("suggested unasked: %+v", s) } } res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true})) st := env.state() var zone string for _, z := range st.ZonesInView(st.ViewByName("internal").ID) { if strings.HasSuffix(z.Name, ".ip6.arpa.") { zone = z.Name } } if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") { t.Errorf("filled %q", p) } if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) { t.Errorf("warnings %q", res.Warnings) } } // ptrsMarked is ptrs with S (shared) or L (local) after each record. func (env *testEnv) ptrsMarked(view, zone string) []string { env.t.Helper() s := env.state() z := s.FindZone(zone, s.ViewByName(view).ID) var out []string for _, r := range s.ZoneRecords(z.ID) { if r.Type == "PTR" { mark := " L" if r.SharedID != 0 { mark = " S" } out = append(out, strings.TrimSuffix(r.Name, ".10.0.10.in-addr.arpa.")+" "+strings.TrimSuffix(r.Data, ".example.com.")+mark) } } return out } // Auto-PTR with linked forward and reverse zones: PTR records follow each // view's own address records, and are local to their view. func TestAutoPTRLinked(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("external"), LinkWith: "internal"})) st := env.state() for _, v := range []string{"internal", "external"} { env.submit(env.admin, NewRequest(OpZoneUpdate, st.FindZone("example.com.", st.ViewByName(v).ID).ID, ZoneInput{AutoPTR: model.Ptr(true)})) } rz := "10.0.10.in-addr.arpa." local := func(view, name, data string) Request { r := recIn(view, name, "A", data) var in RecordInput _ = decode(r.Data, &in) in.Local = model.Ptr(true) return NewRequest(OpRecordCreate, 0, in) } // A local record: a local PTR record in its own view only. env.submit(env.admin, local("internal", "a", "10.0.10.1")) want(t, "local a internal", env.ptrsMarked("internal", rz), "1 a L") want(t, "local a external", env.ptrsMarked("external", rz)) // The other view adds the same name (shared, as it has no local // one): its own PTR record; deleting it in one view leaves the other. env.submit(env.admin, recIn("external", "a", "A", "10.0.10.1")) want(t, "both internal", env.ptrsMarked("internal", rz), "1 a L") want(t, "both external", env.ptrsMarked("external", rz), "1 a L") env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil)) want(t, "deleted in external: internal", env.ptrsMarked("internal", rz), "1 a L") want(t, "deleted in external: external", env.ptrsMarked("external", rz)) // Both local; one changes its address, the other keeps its PTR. env.submit(env.admin, local("external", "a", "10.0.10.1")) env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.10.2")})) want(t, "moved internal", env.ptrsMarked("internal", rz), "2 a L") want(t, "moved external", env.ptrsMarked("external", rz), "1 a L") // Disabling in one view disables there only. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "a", "A"), RecordInput{Disabled: model.Ptr(true)})) want(t, "disabled external", env.ptrs("external", rz), "1.10.0.10.in-addr.arpa. a.example.com. disabled") want(t, "disabled: internal", env.ptrsMarked("internal", rz), "2 a L") env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil)) env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil)) want(t, "gone internal", env.ptrsMarked("internal", rz)) want(t, "gone external", env.ptrsMarked("external", rz)) // A shared record: a local PTR in each view; deleting it removes both. env.submit(env.admin, recIn("internal", "b", "A", "10.0.10.3")) want(t, "shared internal", env.ptrsMarked("internal", rz), "3 b L") want(t, "shared external", env.ptrsMarked("external", rz), "3 b L") // Overridden in one view with another address: that view's PTR moves. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")})) want(t, "override internal", env.ptrsMarked("internal", rz), "3 b L") want(t, "override external", env.ptrsMarked("external", rz), "4 b L") // Sharing internal's version: external gets it back, and its PTR. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "b", "A"), RecordInput{Local: model.Ptr(false)})) want(t, "shared again internal", env.ptrsMarked("internal", rz), "3 b L") want(t, "shared again external", env.ptrsMarked("external", rz), "3 b L") // The override deleted: external goes without b (and its PTR); // internal keeps it, local now. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")})) env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "b", "A"), nil)) want(t, "override gone internal", env.ptrsMarked("internal", rz), "3 b L") want(t, "override gone external", env.ptrsMarked("external", rz)) env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "b", "A"), nil)) want(t, "b gone internal", env.ptrsMarked("internal", rz)) // A shared PTR record from before (shared by hand) becomes local in // each view when one view's address goes. env.submit(env.admin, recIn("internal", "c", "A", "10.0.10.5")) env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR")})) env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "external", Name: model.Ptr("5"), Type: model.Ptr("PTR")})) env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR"), Data: model.Ptr("c.example.com."), Local: model.Ptr(false)})) want(t, "shared ptr external", env.ptrsMarked("external", rz), "5 c S") env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "c", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.6")})) want(t, "unshared internal", env.ptrsMarked("internal", rz), "5 c L") want(t, "unshared external", env.ptrsMarked("external", rz), "6 c L") }