Files
jarl/internal/engine/engine_test.go
abrown38andClaude Opus 5.5 bf7f99874b Web UI: zone naming and reverse zones, ACL suggestions, record form
- New zone dialog: choose Domain or Reverse zone. Reverse zones are
  created from a network and their name is generated; both kinds show the
  exact zone name and flag likely mistakes (addresses or URLs as names,
  host-like names, duplicates in the view, parent/child zones, networks
  off the octet/nibble boundaries) before anything is submitted.
- Record form: clears every field but the type after an add; shows the
  record exactly as it will be stored and warns about unqualified names.
  In reverse zones it takes an address, defaults to PTR, suggests host
  names of A/AAAA records with that address and warns about a second PTR;
  the record list shows addresses.
- Address match lists (options, view match lists, ACL elements) offer the
  existing ACLs, TSIG keys and built-ins as one-click suggestions and
  flag unknown names.
- Server: in reverse zones a record can be named by its address, and a
  PTR target like nas.example.com without the final dot is read as the
  full host name (a bare label is refused). Warnings noticed while parsing
  record data reach the result again.
- Fixes: brand link hover underline, the dashboard's "New zone" link
  opening the dialog twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:49:43 +00:00

465 lines
19 KiB
Go

package engine
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/store"
)
type testEnv struct {
t *testing.T
ctx context.Context
e *Engine
st *store.Store
admin *auth.Principal
dir string
}
func newEnv(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "jarl.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
cfg := Config{
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
StagingDir: filepath.Join(dir, "staging"),
NoReload: true,
SkipCheck: true,
}
e := New(st, nil, cfg, nil)
e.Now = func() time.Time { return time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) }
admin := &model.User{Username: "admin", Admin: true}
if err := st.CreateUser(context.Background(), admin); err != nil {
t.Fatal(err)
}
env := &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir}
// Defaults for new zones.
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.com."}, RName: model.Ptr("hostmaster@example.com")}))
return env
}
func (env *testEnv) submit(p *auth.Principal, reqs ...Request) *Result {
env.t.Helper()
res, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{})
if err != nil {
env.t.Fatalf("submit %v: %v (%+v)", reqs[0].Op, err, res)
}
return res
}
func (env *testEnv) submitErr(p *auth.Principal, reqs ...Request) *Error {
env.t.Helper()
_, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{})
if err == nil {
env.t.Fatalf("submit %v succeeded, want error", reqs[0].Op)
}
ee, ok := AsError(err)
if !ok {
env.t.Fatalf("unexpected error type %T: %v", err, err)
}
return ee
}
func (env *testEnv) state() *model.State {
s, err := env.st.LoadState(env.ctx)
if err != nil {
env.t.Fatal(err)
}
return s
}
func rec(zone, name, typ, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: zone, Name: &name, Type: &typ, Data: &data})
}
func (env *testEnv) records(zone string) []string {
s := env.state()
zs := s.ZonesNamed(zone)
var out []string
for _, r := range s.ZoneRecords(zs[0].ID) {
out = append(out, r.Name+" "+r.Type+" "+r.Data)
}
return out
}
func TestZoneLifecycle(t *testing.T) {
env := newEnv(t)
res := env.submit(env.admin,
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}),
rec("example.com", "ns1", "A", "192.0.2.1"),
rec("example.com", "www", "A", "192.0.2.10"),
)
if !res.Applied || len(res.Files) != 2 || !strings.HasSuffix(res.Files[0], "etc/named.conf") || !strings.HasSuffix(res.Files[1], "example.com.zone") {
t.Fatalf("result = %+v", res)
}
s := env.state()
z := s.ZonesNamed("example.com")[0]
if z.SOA.Serial != 2026100700 || z.SOA.MName != "ns1.example.com." || z.SOA.RName != "hostmaster.example.com." || z.DefaultTTL != 3600 {
t.Errorf("zone defaults = %+v", z.SOA)
}
got := strings.Join(env.records("example.com"), "|")
if got != "example.com. NS ns1.example.com.|ns1.example.com. A 192.0.2.1|www.example.com. A 192.0.2.10" {
t.Errorf("records = %s", got)
}
zf, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/example.com.zone"))
if err != nil || !strings.Contains(string(zf), "www\t\tIN\tA\t192.0.2.10") {
t.Errorf("zone file: %v\n%s", err, zf)
}
// A change bumps the serial once.
env.submit(env.admin, rec("example.com", "mail", "A", "192.0.2.25"), rec("example.com", "@", "MX", "10 mail"))
if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 {
t.Errorf("serial after change = %d", s)
}
// Comment-only changes don't.
r := findRec(env.state(), "www.example.com.", "A")
env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Comment: model.Ptr("web server")}))
if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 {
t.Errorf("serial after comment change = %d", s)
}
}
func findRec(s *model.State, name, typ string) *model.Record {
for _, r := range s.Records {
if r.Name == name && r.Type == typ {
return r
}
}
return nil
}
func setupZone(env *testEnv) {
env.submit(env.admin,
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}),
rec("example.com", "ns1", "A", "192.0.2.1"),
)
}
func TestConflictsRejected(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1"))
// The headline rule: no A record next to a CNAME.
e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
if e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") {
t.Errorf("error = %+v", e)
}
// Bad data is rejected before validation.
e = env.submitErr(env.admin, rec("example.com", "bad", "A", "300.1.1.1"))
if e.Kind != KindBadRequest {
t.Errorf("bad data error = %+v", e)
}
// Swapping CNAME for A works when both happen in one changeset.
r := findRec(env.state(), "www.example.com.", "CNAME")
env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "www", "A", "192.0.2.10"))
if findRec(env.state(), "www.example.com.", "A") == nil {
t.Error("swap failed")
}
// Nothing is stored when a submit fails.
cs, _ := env.st.ListChangesets(env.ctx, store.ChangesetFilter{})
for _, c := range cs {
if c.Status != model.ChangesetApplied {
t.Errorf("unexpected changeset %+v", c)
}
}
}
func TestStagedChangeset(t *testing.T) {
env := newEnv(t)
setupZone(env)
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "add web")
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "CNAME", "ns1")}, env.admin); err != nil {
t.Fatal(err)
}
// Staging a conflicting record is rejected right away.
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "TXT", "hello")}, env.admin); err == nil {
t.Error("conflicting record staged")
}
prev, err := env.e.Preview(env.ctx, cs.ID, env.admin)
if err != nil || !prev.Valid || !strings.Contains(prev.Diff, "+www\t\tIN\tCNAME\tns1.example.com.") {
t.Fatalf("preview = %+v %v", prev, err)
}
if findRec(env.state(), "www.example.com.", "CNAME") != nil {
t.Error("preview changed the state")
}
// Someone else changes the configuration meanwhile.
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err == nil {
t.Fatal("stale changeset applied")
}
// Staging the delete of the conflicting A record fixes the
// changeset (it removes an error rather than adding one).
a := findRec(env.state(), "www.example.com.", "A")
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, a.ID, nil)}, env.admin); err != nil {
t.Fatal(err)
}
res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
if err != nil || !res.Applied {
t.Fatalf("apply: %v %+v", err, res)
}
got, _ := env.st.GetChangeset(env.ctx, cs.ID)
if got.Status != model.ChangesetApplied || got.Diff == "" {
t.Errorf("changeset = %+v", got)
}
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "x", "A", "192.0.2.3")}, env.admin); err == nil {
t.Error("staged into applied changeset")
}
}
func TestVersionConflict(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
r := findRec(env.state(), "www.example.com.", "A")
cs, _ := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")})}, env.admin); err != nil {
t.Fatal(err)
}
env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
_, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
if ee, ok := AsError(err); !ok || ee.Kind != KindConflict {
t.Errorf("expected version conflict, got %v", err)
}
}
func TestPermissions(t *testing.T) {
env := newEnv(t)
setupZone(env)
g, _ := auth.ParseGrant("records:write zones=example.com names=_acme-challenge.* types=TXT")
u := &model.User{Username: "acme", Grants: []model.Grant{g}}
env.st.CreateUser(env.ctx, u)
p := &auth.Principal{User: u, Method: "token"}
env.submit(p, rec("example.com", "_acme-challenge.www", "TXT", "token123"))
if e := env.submitErr(p, rec("example.com", "www", "A", "192.0.2.66")); e.Kind != KindForbidden {
t.Errorf("A record allowed: %+v", e)
}
if e := env.submitErr(p, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("evil.com")})); e.Kind != KindForbidden {
t.Errorf("zone create allowed: %+v", e)
}
// ACME cleanup: delete by match, idempotent.
del := NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123")})
env.submit(p, del)
if findRec(env.state(), "_acme-challenge.www.example.com.", "TXT") != nil {
t.Error("challenge not deleted")
}
e := env.submitErr(p, del)
if e.Kind != KindNotFound {
t.Errorf("second delete = %+v", e)
}
env.submit(p, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123"), MissingOK: true}))
}
func TestPTRAndRRSet(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")}))
in := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true}
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, in))
if len(res.Ops) != 2 || res.Ops[1].Summary != "add 20.2.0.192.in-addr.arpa. IN PTR nas.example.com." {
t.Fatalf("ops = %+v", res.Ops)
}
ptr := findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR")
if ptr == nil || ptr.Data != "nas.example.com." {
t.Fatalf("PTR = %+v", ptr)
}
// Changing the address moves the PTR.
a := findRec(env.state(), "nas.example.com.", "A")
env.submit(env.admin, NewRequest(OpRecordUpdate, a.ID, RecordInput{Data: model.Ptr("192.0.2.21"), PTR: true}))
if findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR") != nil || findRec(env.state(), "21.2.0.192.in-addr.arpa.", "PTR") == nil {
t.Error("PTR not moved")
}
// RRset replace keeps matching records and sets the TTL on all.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", TTL: model.Some(uint32(60)), Data: []string{"192.0.2.30", "192.0.2.31"}}))
keep := findRecData(env.state(), "api.example.com.", "192.0.2.30")
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.32"}}))
s := env.state()
if findRecData(s, "api.example.com.", "192.0.2.31") != nil || findRecData(s, "api.example.com.", "192.0.2.32") == nil {
t.Error("rrset replace wrong")
}
if k := findRecData(s, "api.example.com.", "192.0.2.30"); k == nil || k.ID != keep.ID || *k.TTL != 60 || *findRecData(s, "api.example.com.", "192.0.2.32").TTL != 60 {
t.Error("rrset replace did not keep record or TTL")
}
// A new record joining an RRset inherits its TTL.
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.33"))
if r := findRecData(env.state(), "api.example.com.", "192.0.2.33"); r.TTL == nil || *r.TTL != 60 {
t.Error("TTL not inherited")
}
// A conflicting TTL is rejected.
ttl := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.34"), TTL: model.Some(uint32(300))}
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, ttl)); !strings.Contains(e.Message, "same TTL") {
t.Errorf("TTL mismatch error = %+v", e)
}
}
func findRecData(s *model.State, name, data string) *model.Record {
for _, r := range s.Records {
if r.Name == name && r.Data == data {
return r
}
}
return nil
}
func TestRevert(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
before := env.records("example.com")
r := findRec(env.state(), "www.example.com.", "A")
res := env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "web", "A", "192.0.2.11"))
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
t.Fatal(err)
}
if got := strings.Join(env.records("example.com"), "|"); got != strings.Join(before, "|") {
t.Errorf("after revert %s, want %s", got, strings.Join(before, "|"))
}
// Reverting a zone deletion restores records and options.
z := env.state().ZonesNamed("example.com")[0]
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: &z.ID, Name: model.Ptr("notify"), Value: model.Ptr("no")}))
del := env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
if len(env.state().Zones) != 0 {
t.Fatal("zone not deleted")
}
rev, _ = env.e.Revert(env.ctx, del.ChangesetID, env.admin)
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
t.Fatal(err)
}
s := env.state()
if len(s.Zones) != 1 || len(s.Records) != 3 || s.FindOption(model.ScopeZone, z.ID, "notify") == nil {
t.Errorf("zone not restored: %d zones, %d records", len(s.Zones), len(s.Records))
}
}
func TestViewsAndACLs(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("lan"), Elements: &[]string{"192.168.0.0/16", "10/8"}}))
a := env.state().ACLByName("lan")
if strings.Join(a.Elements, ",") != "192.168.0.0/16,10.0.0.0/8" {
t.Errorf("elements = %v", a.Elements)
}
// Creating a view moves existing zones into it.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"lan", "localhost"}, AdoptZones: true}))
s := env.state()
v := s.ViewByName("internal")
if s.ZonesNamed("example.com")[0].ViewID != v.ID {
t.Error("zone not adopted into view")
}
// A view without adopt_zones and zones outside views is rejected.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
// Clone the zone into the external view.
z := env.state().ZonesNamed("example.com")[0]
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), CloneFrom: z.ID}))
if zs := env.state().ZonesNamed("example.com"); len(zs) != 2 {
t.Fatalf("clone failed: %d zones", len(zs))
}
// Records now need a view to say which zone.
if e := env.submitErr(env.admin, rec("example.com", "x", "A", "192.0.2.5")); !strings.Contains(e.Message, "several views") {
t.Errorf("ambiguous zone error = %+v", e)
}
// Renaming an ACL rewrites references.
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &v.ID, Name: model.Ptr("allow-recursion"), Value: model.Ptr("{ lan; }")}))
env.submit(env.admin, NewRequest(OpACLUpdate, a.ID, ACLInput{Name: model.Ptr("home")}))
s = env.state()
if strings.Join(s.ViewByName("internal").MatchClients, ",") != "home,localhost" || s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value != "{ home; }" {
t.Errorf("references not renamed: %v %q", s.ViewByName("internal").MatchClients, s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value)
}
// Deleting an ACL in use is refused with the users listed.
e := env.submitErr(env.admin, NewRequest(OpACLDelete, a.ID, nil))
if !strings.Contains(e.Message, "view internal") || !strings.Contains(e.Message, "allow-recursion") {
t.Errorf("delete in-use ACL error = %+v", e)
}
// Invalid options are rejected with the catalog's help.
e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("recursion"), Value: model.Ptr("maybe")}))
if !strings.Contains(e.Message, "boolean") {
t.Errorf("bad option error = %+v", e)
}
e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("match-clients"), Value: model.Ptr("{ any; }")}))
if e.Kind != KindBadRequest {
t.Errorf("option in wrong scope = %+v", e)
}
// String options get quoted automatically.
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("directory"), Value: model.Ptr("/var/cache/bind")}))
if o := env.state().FindOption(model.ScopeOptions, 0, "directory"); o.Value != `"/var/cache/bind"` {
t.Errorf("directory = %q", o.Value)
}
}
func TestTSIGAndDrift(t *testing.T) {
env := newEnv(t)
setupZone(env)
res := env.submit(env.admin, NewRequest(OpTSIGCreate, 0, TSIGInput{Name: model.Ptr("xfer")}))
if strings.Contains(string(res.Ops[0].Data), `"secret":"`) && !strings.Contains(string(res.Ops[0].Data), "(hidden)") {
t.Errorf("secret not redacted: %s", res.Ops[0].Data)
}
k := env.state().KeyByName("xfer")
if k == nil || len(k.Secret) != 44 {
t.Fatalf("key = %+v", k)
}
if !strings.Contains(res.Diff, "contents hidden") || strings.Contains(res.Diff, k.Secret) {
t.Errorf("secret in diff:\n%s", res.Diff)
}
// Edit a managed file behind Jarl's back.
zf := filepath.Join(env.dir, "zones/_default/example.com.zone")
os.WriteFile(zf, []byte("hand edited\n"), 0o640)
e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
if e.Kind != KindDrift {
t.Errorf("drift not detected: %+v", e)
}
if _, err := env.e.Submit(env.ctx, []Request{rec("example.com", "www", "A", "192.0.2.10")}, env.admin, "test", "", ApplyOptions{Force: true}); err != nil {
t.Errorf("forced apply: %v", err)
}
if b, _ := os.ReadFile(zf); strings.Contains(string(b), "hand edited") {
t.Error("drifted file not overwritten")
}
}
func TestZoneCreateWithOptions(t *testing.T) {
env := newEnv(t)
// A secondary zone is only valid together with its primaries option.
e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave")}))
if e.Kind != KindValidation || !strings.Contains(e.Message, "primaries") {
t.Errorf("secondary without primaries = %+v", e)
}
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave"),
Options: []ZoneOption{{Name: "masters", Value: "{ 192.0.2.53; }"}}}))
s := env.state()
z := s.ZonesNamed("partner.example")[0]
if z.Type != model.ZoneSecondary {
t.Errorf("type = %s", z.Type)
}
o := s.FindOption(model.ScopeZone, z.ID, "primaries")
if o == nil || o.Value != "{ 192.0.2.53; }" {
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
}
}
func TestParseWarningsReachTheResult(t *testing.T) {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.10.0.0/24")}))
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "0.10.10.in-addr.arpa", Name: model.Ptr("10.10.0.20"), Type: model.Ptr("PTR"), Data: model.Ptr("nas.example.com")}))
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "read as the absolute name") {
t.Errorf("warnings = %q", res.Warnings)
}
if len(res.Ops) != 1 || !strings.Contains(res.Ops[0].Summary, "20.0.10.10.in-addr.arpa. IN PTR nas.example.com.") {
t.Errorf("ops = %+v", res.Ops)
}
}