- 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>
465 lines
19 KiB
Go
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)
|
|
}
|
|
}
|