Files
jarl/internal/importer/importer_test.go
T
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:03:13 +00:00

149 lines
4.9 KiB
Go

package importer
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
"jarl/internal/validate"
)
// copyTree copies testdata/<name> to a temp dir, replacing @DIR@.
func copyTree(t *testing.T, name string) string {
t.Helper()
dst := t.TempDir()
src := filepath.Join("testdata", name)
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
t.Fatal(err)
}
b = []byte(strings.ReplaceAll(string(b), "@DIR@", dst))
if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil {
t.Fatal(err)
}
}
return dst
}
func seqAlloc() func(context.Context, int) ([]int64, error) {
next := int64(0)
return func(_ context.Context, n int) ([]int64, error) {
out := make([]int64, n)
for i := range out {
next++
out[i] = next
}
return out, nil
}
}
func TestImportDebian(t *testing.T) {
dir := copyTree(t, "debian")
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc(), User: "import"})
if err != nil {
t.Fatal(err)
}
// rndc.key stays an include; its secret is not imported.
if len(rep.Kept) != 1 || !strings.HasSuffix(rep.Kept[0], "rndc.key") {
t.Errorf("kept = %v", rep.Kept)
}
if st.KeyByName("rndc-key") != nil {
t.Error("rndc key imported")
}
if k := st.KeyByName("xfer-key"); k == nil || k.Algorithm != "hmac-sha256" {
t.Errorf("xfer-key = %+v", k)
}
if a := st.ACLByName("homelab"); a == nil || strings.Join(a.Elements, ",") != "192.168.1.0/24,10.10.0.0/16,localhost" {
t.Errorf("acl = %+v", a)
}
if o := st.FindOption(model.ScopeOptions, 0, "allow-recursion"); o == nil || o.Value != "{ homelab; }" {
t.Errorf("allow-recursion = %+v", o)
}
if st.FindOption(model.ScopeTopLevel, 0, "controls") == nil || st.FindOption(model.ScopeTopLevel, 0, "logging") == nil {
t.Error("controls/logging not kept")
}
z := st.FindZone("adbraun.lol", 0)
if z == nil || z.Type != model.ZonePrimary || z.SOA.Serial != 2024051201 || z.SOA.RName != "admin.adbraun.lol." || z.DefaultTTL != 3600 {
t.Fatalf("zone = %+v", z)
}
if st.FindOption(model.ScopeZone, z.ID, "allow-transfer") == nil {
t.Error("zone option lost")
}
recs := map[string]*model.Record{}
for _, r := range st.ZoneRecords(z.ID) {
recs[r.Name+" "+r.Type+" "+r.Data] = r
}
if r := recs["nas.adbraun.lol. A 192.168.1.20"]; r == nil || r.Comment != "synology" || r.TTL != nil {
t.Errorf("nas = %+v", r)
}
if r := recs["www.adbraun.lol. A 192.168.1.11"]; r == nil || r.TTL == nil || *r.TTL != 300 {
t.Errorf("www = %+v", r)
}
if recs["adbraun.lol. MX 10 mail.adbraun.lol."] == nil || recs["media.adbraun.lol. CNAME nas.adbraun.lol."] == nil {
t.Errorf("records = %v", recs)
}
rz := st.FindZone("1.168.192.in-addr.arpa", 0)
if rz == nil {
t.Fatal("reverse zone missing")
}
if n := len(st.ZoneRecords(rz.ID)); n != 6 { // NS, 2 PTR, 3 generated PTR
t.Errorf("reverse zone has %d records", n)
}
if fz := st.FindZone("consul", 0); fz == nil || fz.Type != model.ZoneForward {
t.Error("forward zone missing")
}
// The imported configuration is valid (the open allow-query is fine
// because allow-recursion is restricted).
ps := validate.State(st, validate.Options{ExternalKeys: []string{"rndc-key"}})
if validate.HasErrors(ps) {
t.Errorf("imported state has errors: %v", validate.Errors(ps))
}
for _, p := range ps {
if p.Code == "security_open_resolver" {
t.Error("false open resolver warning")
}
}
if rep.Counts["zones"] != 3 || len(rep.Zones) != 3 {
t.Errorf("report = %+v", rep)
}
}
func TestImportErrors(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`include "`+dir+`/missing.conf";`), 0o644)
if _, _, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}); err == nil {
t.Error("missing include accepted")
}
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; };`), 0o644)
os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\nbad_name IN A 192.0.2.2\nother.test. IN A 192.0.2.3\n"), 0o644)
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
if err != nil {
t.Fatal(err)
}
if len(rep.Skipped) != 2 || len(st.Records) != 2 {
t.Errorf("skipped = %v, records = %d", rep.Skipped, len(st.Records))
}
}
func TestParseTTL(t *testing.T) {
cases := map[string]uint32{"3600": 3600, "1h": 3600, "1h30m": 5400, "1W": 604800, "2d3s": 172803}
for in, want := range cases {
if got, ok := ParseTTL(in); !ok || got != want {
t.Errorf("ParseTTL(%q) = %d %v", in, got, ok)
}
}
for _, bad := range []string{"", "h", "1x", "-5"} {
if _, ok := ParseTTL(bad); ok {
t.Errorf("ParseTTL(%q) accepted", bad)
}
}
}