package console import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "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/kubeedit" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" ) // validator builds a console whose cluster holds the given groups and whose // resolver reports one group of its own, defined in its config file. func validator(t *testing.T, saved ...config.Group) *Console { t.Helper() cl := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList", kubesource.ListGVR: "BlockListList", kubesource.OverrideGVR: "AddressOverrideList", kubesource.RuleGVR: "DomainRuleList", }) ed := kubeedit.New(cl, "dns") if len(saved) > 0 { if _, err := ed.SyncGroups(context.Background(), saved, nil, "test"); err != nil { t.Fatal(err) } } resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/clients": groups := []map[string]any{{ "name": "from-file", "policy": "allow", "source": "", "match": map[string]any{"cidrs": []string{"172.16.0.0/12"}}, }} for _, g := range saved { groups = append(groups, map[string]any{"name": g.Name, "policy": g.Policy, "source": "kubernetes", "match": g.Match}) } json.NewEncoder(w).Encode(map[string]any{"groups": groups, "default_policy": "allow", "default_rate_limit": 50}) case "/api/upstreams": json.NewEncoder(w).Encode(map[string]any{"servers": []map[string]any{{"addr": "1.1.1.1:53"}}}) default: http.NotFound(w, r) } })) t.Cleanup(resolver.Close) rt, err := NewRuntime(resolver.URL, "") if err != nil { t.Fatal(err) } return &Console{opt: Options{Runtime: rt, Editor: ed}, log: slog.Default()} } func validate(t *testing.T, c *Console, body string) validateResponse { t.Helper() rec := httptest.NewRecorder() c.handleClientsValidate(rec, httptest.NewRequest(http.MethodPost, "/api/clients/validate", strings.NewReader(body))) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var out validateResponse if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("%v: %s", err, rec.Body) } return out } func hasField(list []string, f string) bool { for _, s := range list { if s == f { return true } } return false } func errFields(r validateResponse) []string { var out []string for _, e := range r.Errors { out = append(out, e.Field) } return out } func TestValidateReportsEveryErrorAtOnce(t *testing.T) { c := validator(t) r := validate(t, c, `{"group":{"name":"Bad Name","policy":"sometimes","match":{"cidrs":["10.0.0.0/33"]},"rate_limit":-1}}`) if r.OK { t.Fatal("a group with four problems validated") } for _, want := range []string{"name", "policy", "match.cidrs", "rate_limit"} { if !hasField(errFields(r), want) { t.Errorf("no error for %q; got %+v", want, r.Errors) } } } func TestValidateAcceptsAGoodGroupAndExplainsIt(t *testing.T) { c := validator(t) r := validate(t, c, `{"group":{"name":"kids","policy":"allow","match":{"cidrs":["10.0.5.0/24"]},"allow_domains":["*.school.example"]}}`) if !r.OK || len(r.Errors) != 0 { t.Fatalf("errors: %+v", r.Errors) } if r.Position != 2 || r.Total != 2 { t.Errorf("a new group goes last: position %d of %d", r.Position, r.Total) } if len(r.Summary) < 3 { t.Errorf("summary = %v", r.Summary) } // 0 rate limit under a default of 50 is the surprise worth flagging. found := false for _, w := range r.Warnings { found = found || w.Field == "rate_limit" } if !found { t.Errorf("warnings = %+v, want the rate-limit exemption", r.Warnings) } } func TestValidateEditingKeepsThePosition(t *testing.T) { a := config.Group{Name: "a", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}} b := config.Group{Name: "b", Policy: "allow", Match: config.Match{CIDRs: []string{"192.168.0.0/16"}}} c := validator(t, a, b) r := validate(t, c, `{"group":{"name":"a","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}}}`) if r.Position != 2 || r.Total != 3 { // from-file, a, b t.Errorf("position %d of %d", r.Position, r.Total) } } func TestValidateRefusesAFileGroupName(t *testing.T) { c := validator(t) r := validate(t, c, `{"group":{"name":"from-file","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}}}`) if r.OK || !hasField(errFields(r), "name") { t.Errorf("a group the resolver's file owns validated: %+v", r) } } func TestValidateFlagsAGroupBuriedUnderAWiderOne(t *testing.T) { wide := config.Group{Name: "wide", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}} c := validator(t, wide) r := validate(t, c, `{"group":{"name":"narrow","policy":"deny","match":{"cidrs":["10.1.0.0/16"]}}}`) if !r.OK || !r.Unreachable { t.Errorf("ok=%v unreachable=%v warnings=%+v", r.OK, r.Unreachable, r.Warnings) } } func TestValidateTestIP(t *testing.T) { g := config.Group{Name: "lan", Policy: "deny", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}} c := validator(t, g) r := validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"10.1.2.3"}`) if r.Test == nil || r.Test.Group != "lan" || r.Test.Default { t.Errorf("test = %+v", r.Test) } r = validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"8.8.8.8"}`) if r.Test == nil || !r.Test.Default { t.Errorf("test = %+v", r.Test) } r = validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"nope"}`) if r.Test == nil || r.Test.Valid { t.Errorf("test = %+v", r.Test) } } func TestValidateUnreachableResolverIsABadGateway(t *testing.T) { c := validator(t) c.opt.Runtime, _ = NewRuntime("http://127.0.0.1:1", "") rec := httptest.NewRecorder() c.handleClientsValidate(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"group":{"name":"x"}}`))) if rec.Code != http.StatusBadGateway { t.Errorf("status %d", rec.Code) } }