Files
jarl/internal/importer/importer_test.go
abrown38andClaude Opus 5.5 24a2c875fa Merge branch 'dnssec'
The DNSSEC notes in CHANGELOG.md belong under Unreleased; git had put
them into the 0.1.0 section, which didn't exist when the branch was made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:16:33 +00:00

208 lines
7.2 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)
}
}
}
func TestImportAllowUpdateWarning(t *testing.T) {
dir := t.TempDir()
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\n"), 0o644)
for conf, want := range map[string]bool{
`allow-update { none; };`: false,
`allow-update { 127.0.0.1; };`: true,
`update-policy local;`: true,
} {
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; `+conf+` };`), 0o644)
_, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
if err != nil {
t.Fatal(err)
}
if got := len(rep.Warnings) > 0; got != want {
t.Errorf("%s: warnings = %v", conf, rep.Warnings)
}
}
}
func TestImportSignedZones(t *testing.T) {
dir := t.TempDir()
zone := "$TTL 300\n@ IN SOA ns1 h 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"
for _, n := range []string{"a", "b", "c"} {
os.WriteFile(filepath.Join(dir, n+".db"), []byte(strings.ReplaceAll(zone, "ns1", "ns1."+n+".test.")), 0o644)
}
conf := `options { directory "` + dir + `"; dnssec-policy default; key-directory "/var/cache/bind/keys"; };
view "external" {
zone "a.test" { type primary; file "a.db"; key-directory "/elsewhere"; };
zone "b.test" { type primary; file "b.db"; dnssec-policy none; };
};
view "internal" {
dnssec-policy "none";
zone "c.test" { type primary; file "c.db"; };
};`
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(conf), 0o644)
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
if err != nil {
t.Fatal(err)
}
policy := map[string]string{}
for _, z := range st.Zones {
policy[z.Name] = z.DNSSECPolicy
}
if policy["a.test."] != "default" || policy["b.test."] != "" || policy["c.test."] != "" {
t.Errorf("policies: %v", policy)
}
for _, o := range st.Options {
if o.Name == "dnssec-policy" || o.Scope == model.ScopeZone && o.Name == "key-directory" {
t.Errorf("option kept: %+v", o)
}
}
if !strings.Contains(strings.Join(rep.Warnings, "\n"), "zone a.test is signed (dnssec-policy default). Before 'jarld adopt', copy its key files (Ka.test.+*)") {
t.Errorf("warnings: %v", rep.Warnings)
}
if ps := validate.Errors(validate.State(st, validate.Options{})); len(ps) != 0 {
t.Errorf("imported state has errors: %v", ps)
}
}