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