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) } }