package kubeedit import ( "context" "errors" "strings" "testing" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" 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/kubesource" ) func newEditor() *Editor { c := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList", kubesource.ListGVR: "BlockListList", kubesource.OverrideGVR: "AddressOverrideList", kubesource.RuleGVR: "DomainRuleList", }) return New(c, "dns") } var ctx = context.Background() func grp(name, policy string) config.Group { return config.Group{Name: name, Policy: policy, Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}} } func TestSyncGroupsCreatesUpdatesDeletesAndOrders(t *testing.T) { e := newEditor() res, err := e.SyncGroups(ctx, []config.Group{grp("a", "allow"), grp("b", "deny")}, nil, "alice") if err != nil || len(res.Created) != 2 { t.Fatalf("create: %+v %v", res, err) } // Nothing changed: nothing written. res, err = e.SyncGroups(ctx, []config.Group{grp("a", "allow"), grp("b", "deny")}, nil, "alice") if err != nil || len(res.Created)+len(res.Updated)+len(res.Deleted) != 0 { t.Fatalf("idempotent: %+v %v", res, err) } // Reorder, change one, drop none. res, err = e.SyncGroups(ctx, []config.Group{grp("b", "deny"), grp("a", "block_only")}, nil, "alice") if err != nil || len(res.Updated) != 2 { t.Fatalf("reorder: %+v %v", res, err) } got, _ := e.Groups(ctx) if got[0].Name != "b" || got[1].Name != "a" || got[1].Spec.Policy != "block_only" { t.Fatalf("groups = %+v", got) } res, err = e.SyncGroups(ctx, []config.Group{grp("b", "deny")}, nil, "alice") if err != nil || len(res.Deleted) != 1 || res.Deleted[0] != "a" { t.Fatalf("delete: %+v %v", res, err) } } func TestSyncGroupsLeavesForeignGroupsAlone(t *testing.T) { e := newEditor() yaml := func(n string) bool { return n == "from-yaml" } res, err := e.SyncGroups(ctx, []config.Group{grp("from-yaml", "allow"), grp("mine", "deny")}, yaml, "alice") if err != nil || len(res.Created) != 1 || res.Created[0] != "mine" { t.Fatalf("%+v %v", res, err) } } func TestSyncGroupsValidatesBeforeWriting(t *testing.T) { e := newEditor() _, err := e.SyncGroups(ctx, []config.Group{grp("ok", "allow"), grp("bad", "maybe")}, nil, "alice") if err == nil || !strings.Contains(err.Error(), "bad") { t.Fatalf("err = %v", err) } if got, _ := e.Groups(ctx); len(got) != 0 { t.Fatalf("a rejected save wrote %+v", got) } if _, err := e.SyncGroups(ctx, []config.Group{grp("Kids Room", "allow")}, nil, "alice"); err == nil { t.Fatal("a name that is not a Kubernetes object name must be refused") } } func TestActorIsRecorded(t *testing.T) { e := newEditor() if _, err := e.SyncGroups(ctx, []config.Group{grp("a", "allow")}, nil, "alice@example.com"); err != nil { t.Fatal(err) } u, err := e.res(kubesource.GroupGVR).Get(ctx, "a", getOpts) if err != nil { t.Fatal(err) } if u.GetAnnotations()[ActorAnnotation] != "alice@example.com" { t.Fatalf("annotations = %v", u.GetAnnotations()) } } func TestZoneRecordLifecycle(t *testing.T) { e := newEditor() const apex = "home.example.com." // A zone starts with its servers: without an NS the runtime cannot answer // NXDOMAIN and the editor says so rather than storing a dead zone. if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", Value: "10.0.0.2"}, "a"); err == nil { t.Fatal("a zone with no NS must be refused") } if zs, _ := e.Zones(ctx); len(zs) != 0 { t.Fatal("the refused zone was stored") } // An apex NS on its own is a valid zone. if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "@", Type: "NS", Value: "ns1.home.example.com."}, "a"); err != nil { t.Fatal(err) } res, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", TTL: 60, Value: "10.0.0.2"}, "a") if err != nil || res.Serial < 1 { t.Fatalf("add: %+v %v", res, err) } // Names are stored the way an operator writes them. zs, _ := e.Zones(ctx) if len(zs) != 1 || zs[0].Name != "home.example.com" { t.Fatalf("zones = %+v", zs) } var names []string for _, r := range zs[0].Spec.Records { names = append(names, r.Name) } if strings.Join(names, ",") != "@,nas" { t.Fatalf("stored names = %v", names) } // The same record, spelled differently, is a duplicate. if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas.home.example.com.", Type: "A", Value: "10.0.0.2"}, "a"); err == nil { t.Fatal("duplicate accepted") } // Put replaces the RRset. if _, err := e.PutRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", Value: "10.0.0.9"}, "a"); err != nil { t.Fatal(err) } zs, _ = e.Zones(ctx) if n := countType(zs[0].Spec, "A"); n != 1 || zs[0].Spec.Records[len(zs[0].Spec.Records)-1].Value != "10.0.0.9" { t.Fatalf("after put = %+v", zs[0].Spec.Records) } // Delete one record, then one that is not there. if _, err := e.DeleteRecord(ctx, apex, "nas", "A", "10.0.0.9", "a"); err != nil { t.Fatal(err) } if _, err := e.DeleteRecord(ctx, apex, "nas", "A", "", "a"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want not found", err) } // An edit that would leave the zone invalid is refused and changes nothing. if _, err := e.DeleteRecord(ctx, apex, "@", "NS", "", "a"); err == nil { t.Fatal("removing the last apex NS must be refused") } if zs, _ = e.Zones(ctx); countType(zs[0].Spec, "NS") != 1 { t.Fatal("the refused delete changed the zone") } } func countType(s kubesource.DNSZoneSpec, typ string) int { n := 0 for _, r := range s.Records { if r.Type == typ { n++ } } return n } func TestDelegationsAndZoneDelete(t *testing.T) { e := newEditor() const apex = "lab.example.com." // A delegation in a brand new zone also gives the zone its own servers. if _, err := e.SetDelegation(ctx, apex, "sub", []string{"ns.sub.lab.example.com.", "ns2.sub.lab.example.com."}, 300, "a"); err != nil { t.Fatal(err) } zs, _ := e.Zones(ctx) if got := countType(zs[0].Spec, "NS"); got != 4 { t.Fatalf("NS records = %d, want 2 for the cut and 2 for the apex: %+v", got, zs[0].Spec.Records) } // Data under the cut is refused, as in the file-based writer. if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "x.sub", Type: "A", Value: "10.0.0.1"}, "a"); err == nil { t.Fatal("a record below a delegation was accepted") } if _, err := e.RemoveDelegation(ctx, apex, "sub", "a"); err != nil { t.Fatal(err) } if _, err := e.RemoveDelegation(ctx, apex, "sub", "a"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v", err) } if err := e.DeleteZone(ctx, apex); err != nil { t.Fatal(err) } if err := e.DeleteZone(ctx, apex); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v", err) } } func TestListEditing(t *testing.T) { e := newEditor() if err := e.PutList(ctx, "ads", kubesource.BlockListSpec{URL: "https://e.com/hosts", Format: "hosts"}, "a"); err != nil { t.Fatal(err) } if err := e.PutList(ctx, "evil", kubesource.BlockListSpec{URL: "file:///etc/passwd"}, "a"); err == nil { t.Fatal("file:// accepted") } if err := e.SetListDisabled(ctx, "ads", true, "a"); err != nil { t.Fatal(err) } ls, _ := e.Lists(ctx) if len(ls) != 1 || !ls[0].Spec.Disabled || ls[0].Spec.Format != "hosts" { t.Fatalf("lists = %+v", ls) } if err := e.SetListDisabled(ctx, "nope", true, "a"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v", err) } if err := e.DeleteList(ctx, "ads"); err != nil { t.Fatal(err) } } func TestOverrideEditing(t *testing.T) { e := newEditor() if err := e.SetOverride(ctx, "Tornberg.ME.", "10.10.3.10", "a"); err != nil { t.Fatal(err) } // Setting again replaces rather than adds, as the RPZ file editor did. if err := e.SetOverride(ctx, "tornberg.me", "10.10.3.11", "a"); err != nil { t.Fatal(err) } if err := e.SetOverride(ctx, "slask_x.local", "10.10.3.10", "a"); err != nil { t.Fatalf("a name that is not a legal object name must still work: %v", err) } got, _ := e.Overrides(ctx) if len(got) != 2 { t.Fatalf("overrides = %+v", got) } byDomain := map[string][]string{} for _, o := range got { byDomain[o.Domain] = o.Addresses } if a := byDomain["tornberg.me"]; len(a) != 1 || a[0] != "10.10.3.11" { t.Fatalf("tornberg.me = %v", a) } if err := e.SetOverride(ctx, "x.test", "not-an-ip", "a"); err == nil { t.Fatal("a bad address was accepted") } if err := e.DeleteOverride(ctx, "TORNBERG.me."); err != nil { t.Fatal(err) } if err := e.DeleteOverride(ctx, "tornberg.me"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v", err) } if got, _ := e.Overrides(ctx); len(got) != 1 { t.Fatalf("after delete: %+v", got) } } func TestRuleEditing(t *testing.T) { e := newEditor() n, err := e.AddRules(ctx, "block", []string{"Ads.Example.com", "*.tracker.example", "ads.example.com"}, "a") if err != nil || n != 3 { t.Fatalf("add: %d %v", n, err) } if _, err := e.AddRules(ctx, "allow", []string{"good.example"}, "a"); err != nil { t.Fatal(err) } if _, err := e.AddRules(ctx, "block", []string{"bad name\nother.test"}, "a"); err == nil { t.Fatal("a name with a newline was accepted") } if _, err := e.AddRules(ctx, "drop", []string{"x.test"}, "a"); err == nil { t.Fatal("an unknown action was accepted") } items, _ := e.list(ctx, kubesource.RuleGVR) if len(items) != 2 { t.Fatalf("%d DomainRule objects, want one per action", len(items)) } var spec kubesource.DomainRuleSpec for i := range items { if items[i].GetName() == inlineBlock { _ = decodeSpec(&items[i], &spec) } } if len(spec.Names) != 2 { // ads.example.com once, and the wildcard t.Fatalf("block names = %v", spec.Names) } // A wildcard rule is deleted by its plain name, as the console sends it. if removed, err := e.DeleteRule(ctx, "tracker.example", "a"); err != nil || removed != 1 { t.Fatalf("delete: %d %v", removed, err) } if _, err := e.DeleteRule(ctx, "never.added", "a"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v", err) } // Removing the last name of the console's own list removes the object. if _, err := e.DeleteRule(ctx, "good.example", "a"); err != nil { t.Fatal(err) } if items, _ = e.list(ctx, kubesource.RuleGVR); len(items) != 1 { t.Fatalf("%d objects left, want only the block list", len(items)) } } func TestObjectNames(t *testing.T) { for in, same := range map[string]bool{"tornberg.me": true, "a-b.example": true, "slask_x.local": false, "UPPER": false} { got := objectName(in) if (got == in) != same { t.Errorf("objectName(%q) = %q", in, got) } if err := checkName("x", got); err != nil { t.Errorf("objectName(%q) = %q is not a legal object name: %v", in, got, err) } } if objectName("a_b.test") == objectName("a-b.test") { t.Error("distinct names collided") } }