package kubesource import ( "context" "strings" "testing" "time" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" dynfake "k8s.io/client-go/dynamic/fake" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" "git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile" ) func obj(kind, name string, spec map[string]any) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": APIGroup + "/" + Version, "kind": kind, "metadata": map[string]any{"name": name, "namespace": "dns", "generation": int64(3)}, "spec": spec, }} } func cgroup(name string, spec map[string]any) *unstructured.Unstructured { return obj("ClientGroup", name, spec) } func fakeClient(objs ...runtime.Object) *dynfake.FakeDynamicClient { return dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schemaGVR]string{ ZoneGVR: "DNSZoneList", GroupGVR: "ClientGroupList", ListGVR: "BlockListList", }, objs...) } func start(t *testing.T, objs ...runtime.Object) (*Source, *dynfake.FakeDynamicClient, chan *Snapshot) { t.Helper() c := fakeClient(objs...) s, err := New(Options{Client: c, Namespace: "dns", Debounce: 10 * time.Millisecond}) if err != nil { t.Fatal(err) } ch := make(chan *Snapshot, 16) s.OnChange(func(sn *Snapshot) { ch <- sn }) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { _ = s.Run(ctx); close(done) }() t.Cleanup(func() { cancel(); <-done }) return s, c, ch } func next(t *testing.T, ch chan *Snapshot) *Snapshot { t.Helper() select { case sn := <-ch: return sn case <-time.After(5 * time.Second): t.Fatal("no snapshot published") return nil } } var cidrMatch = map[string]any{"cidrs": []any{"10.0.0.0/8"}} func TestGroupsAreOrderedByPriorityThenName(t *testing.T) { _, _, ch := start(t, cgroup("b-kids", map[string]any{"priority": int64(10), "policy": "block_only", "match": cidrMatch}), cgroup("a-kids", map[string]any{"priority": int64(10), "policy": "deny", "match": cidrMatch}), cgroup("first", map[string]any{"priority": int64(1), "policy": "allow", "match": cidrMatch}), ) sn := next(t, ch) var names []string for _, g := range sn.Groups { names = append(names, g.Name) } if got := strings.Join(names, ","); got != "first,a-kids,b-kids" { t.Fatalf("order = %s", got) } } func TestRejectedObjectIsReportedAndOthersSurvive(t *testing.T) { _, _, ch := start(t, cgroup("good", map[string]any{"policy": "allow", "match": cidrMatch}), cgroup("badpolicy", map[string]any{"policy": "maybe", "match": cidrMatch}), cgroup("badcidr", map[string]any{"policy": "deny", "match": map[string]any{"cidrs": []any{"10.0.0.0/33"}}}), cgroup("empty", map[string]any{"policy": "deny", "match": map[string]any{}}), ) sn := next(t, ch) if len(sn.Groups) != 1 || sn.Groups[0].Name != "good" { t.Fatalf("groups = %+v", sn.Groups) } if len(sn.Problems) != 3 { t.Fatalf("problems = %+v", sn.Problems) } } func TestBrokenEditKeepsLastGoodVersion(t *testing.T) { _, c, ch := start(t, cgroup("kids", map[string]any{"policy": "deny", "match": cidrMatch})) if sn := next(t, ch); sn.Groups[0].Policy != "deny" { t.Fatalf("first = %+v", sn.Groups) } // A bad edit changes nothing in force, so it publishes nothing... bad := cgroup("kids", map[string]any{"policy": "bogus", "match": cidrMatch}) if _, err := c.Resource(GroupGVR).Namespace("dns").Update(context.Background(), bad, metav1UpdateOptions); err != nil { t.Fatal(err) } select { case sn := <-ch: t.Fatalf("a rejected edit published a snapshot: %+v", sn.Groups) case <-time.After(300 * time.Millisecond): } // ...and a good edit goes through. good := cgroup("kids", map[string]any{"policy": "block_only", "match": cidrMatch}) if _, err := c.Resource(GroupGVR).Namespace("dns").Update(context.Background(), good, metav1UpdateOptions); err != nil { t.Fatal(err) } if sn := next(t, ch); sn.Groups[0].Policy != "block_only" { t.Fatalf("after good edit = %+v", sn.Groups) } } func TestDeleteRemovesGroup(t *testing.T) { _, c, ch := start(t, cgroup("kids", map[string]any{"policy": "deny", "match": cidrMatch})) next(t, ch) if err := c.Resource(GroupGVR).Namespace("dns").Delete(context.Background(), "kids", metav1DeleteOptions); err != nil { t.Fatal(err) } if sn := next(t, ch); len(sn.Groups) != 0 { t.Fatalf("groups = %+v", sn.Groups) } } func TestBlockListConversion(t *testing.T) { for name, tc := range map[string]struct { spec map[string]any ok bool }{ "https": {map[string]any{"url": "https://example.com/hosts", "format": "hosts", "refresh": "6h"}, true}, "file url": {map[string]any{"url": "file:///etc/shadow"}, false}, "no url": {map[string]any{"format": "hosts"}, false}, "bad format": {map[string]any{"url": "https://e.com/x", "format": "yaml"}, false}, "bad refresh": {map[string]any{"url": "https://e.com/x", "refresh": "soon"}, false}, "bad mirror": {map[string]any{"url": "https://e.com/x", "mirror": []any{"file:///x"}}, false}, } { t.Run(name, func(t *testing.T) { l, err := convertList(obj("BlockList", "ads", tc.spec)) if (err == nil) != tc.ok { t.Fatalf("err = %v", err) } if tc.ok && (!l.Enabled || l.Path != "" || l.Refresh != config.Duration(6*time.Hour)) { t.Fatalf("list = %+v", l) } }) } } func zoneSpec(records ...any) map[string]any { return map[string]any{"zone": "Home.Example.com", "records": records} } func rec(name, typ, value string) map[string]any { return map[string]any{"name": name, "type": typ, "value": value} } func TestZoneRendersAndRoundTrips(t *testing.T) { z, err := convertZone(obj("DNSZone", "home", zoneSpec( rec("@", "NS", "ns1.home.example.com."), rec("ns1", "A", "10.0.0.1"), rec("nas", "A", "10.0.0.2"), rec("*.apps", "A", "10.0.0.3"), ))) if err != nil { t.Fatal(err) } if z.apex != "home.example.com." { t.Fatalf("apex = %q", z.apex) } parsed, err := zonefile.Parse(strings.NewReader(z.text), zonefile.Options{Origin: z.apex, File: "t"}) if err != nil { t.Fatalf("rendered zone does not parse: %v\n%s", err, z.text) } if parsed.SOA == nil || parsed.SOA.Serial != 3 { t.Fatalf("SOA serial should be the object generation: %+v", parsed.SOA) } // Same input, same bytes: replicas must agree. again, _ := convertZone(obj("DNSZone", "home", zoneSpec( rec("@", "NS", "ns1.home.example.com."), rec("ns1", "A", "10.0.0.1"), rec("nas", "A", "10.0.0.2"), rec("*.apps", "A", "10.0.0.3"), ))) if again.text != z.text { t.Fatal("rendering is not deterministic") } store, err := localzone.Load(writeTemp(t, z), localzone.Options{}) if err != nil { t.Fatal(err) } if store.Count() == 0 { t.Fatal("store loaded no records") } } func TestZoneRejections(t *testing.T) { for name, spec := range map[string]map[string]any{ "no apex NS": zoneSpec(rec("nas", "A", "10.0.0.2")), "bad value": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("nas", "A", "not-an-ip")), "outside zone": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("x.other.org.", "A", "10.0.0.2")), "duplicate": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("a", "A", "10.0.0.2"), rec("a", "A", "10.0.0.2")), "under delegated": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("sub", "NS", "ns.else.net."), rec("x.sub", "A", "10.0.0.2")), "soa as record": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("@", "SOA", "a. b. 1 2 3 4 5")), "no zone": {"records": []any{}}, } { t.Run(name, func(t *testing.T) { if _, err := convertZone(obj("DNSZone", "z", spec)); err == nil { t.Fatal("expected a rejection") } }) } } func TestDuplicateApexOldestWins(t *testing.T) { a := obj("DNSZone", "a", zoneSpec(rec("@", "NS", "ns1.home.example.com."))) b := obj("DNSZone", "b", zoneSpec(rec("@", "NS", "ns2.home.example.com."))) a.SetCreationTimestamp(metav1Time(1000)) b.SetCreationTimestamp(metav1Time(2000)) _, _, ch := start(t, b, a) sn := next(t, ch) if len(sn.Zones) != 1 || !strings.Contains(sn.Zones["home.example.com."], "ns1.home.example.com.") { t.Fatalf("zones = %v", sn.Zones) } if len(sn.Problems) != 1 || sn.Problems[0].Name != "b" { t.Fatalf("problems = %+v", sn.Problems) } } func TestMergePrecedenceAndDefaults(t *testing.T) { base := config.Default() base.ACL.Groups = []config.Group{{Name: "yaml", Policy: "allow", Match: config.Match{CIDRs: []string{"192.168.0.0/16"}}}} base.Block.Lists = []config.ListSource{{Name: "ads", URL: "https://a/x", Enabled: true}} snap := &Snapshot{ Groups: []config.Group{{Name: "yaml", Policy: "deny"}, {Name: "crd", Policy: "deny"}}, Lists: []config.ListSource{{Name: "ads", URL: "https://b/x"}, {Name: "crd-list", URL: "https://c/x", Enabled: true}}, } merged, dropped := Merge(base, snap) if len(merged.ACL.Groups) != 2 || merged.ACL.Groups[0].Policy != "allow" || merged.ACL.Groups[1].Name != "crd" { t.Fatalf("groups = %+v", merged.ACL.Groups) } if len(merged.Block.Lists) != 2 || merged.Block.Lists[0].URL != "https://a/x" { t.Fatalf("lists = %+v", merged.Block.Lists) } if got := merged.Block.Lists[1]; got.Format != "auto" || got.Refresh == 0 || got.MaxBytes == 0 { t.Fatalf("defaults not applied: %+v", got) } if len(dropped) != 2 { t.Fatalf("dropped = %v", dropped) } if len(base.ACL.Groups) != 1 || len(base.Block.Lists) != 1 { t.Fatal("Merge modified its input") } } func TestSyncZoneDir(t *testing.T) { dir := t.TempDir() if ch, err := SyncZoneDir(dir, map[string]string{"a.example.": "x", "b.example.": "y"}); err != nil || !ch { t.Fatalf("first sync: %v %v", ch, err) } if ch, err := SyncZoneDir(dir, map[string]string{"a.example.": "x", "b.example.": "y"}); err != nil || ch { t.Fatalf("idempotent sync: %v %v", ch, err) } if ch, err := SyncZoneDir(dir, map[string]string{"a.example.": "x"}); err != nil || !ch { t.Fatalf("removal sync: %v %v", ch, err) } if got := fileNames(t, dir); got != "a.example.zone" { t.Fatalf("files = %s", got) } } func TestSnapshotSaveLoad(t *testing.T) { path := t.TempDir() + "/sub/snap.json" in := &Snapshot{Revision: 4, Groups: []config.Group{{Name: "g", Policy: "allow"}}, Zones: map[string]string{"a.": "z"}} if err := Save(path, in); err != nil { t.Fatal(err) } out, err := Load(path) if err != nil || out.Revision != 4 || out.Groups[0].Name != "g" || out.Zones["a."] != "z" { t.Fatalf("%+v %v", out, err) } if none, err := Load(path + ".missing"); none != nil || err != nil { t.Fatalf("missing file: %v %v", none, err) } }