package app import ( "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/miekg/dns" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" "git.k8s-git-k6n.net/mats/fancy-dns/internal/console" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit" ) // TestConsoleEditsReachTheResolver runs both halves: a console with its own // authentication writing objects, and a resolver watching the same cluster. // They share nothing but the (fake) apiserver and the resolver's admin API, // which is the whole deployment contract. func TestConsoleEditsReachTheResolver(t *testing.T) { cluster := kubeClient() h := newHarness(t, nil, func(o *Options) { o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: cluster, SyncTimeout: 5 * time.Second} }) rt, err := console.NewRuntime("http://"+h.api, h.key) if err != nil { t.Fatal(err) } cfg := config.Default() cfg.API.Auth.Mode = "apikey" cfg.API.Auth.APIKeys = []string{"console-key"} cfg.API.Auth.TrustLoopback = false cfg.API.UI = false c, err := console.New(console.Options{ Config: config.NewStore(cfg, ""), Runtime: rt, Editor: kubeedit.New(cluster, "dns"), Version: "test", }) if err != nil { t.Fatal(err) } handler, err := c.Handler() if err != nil { t.Fatal(err) } ts := httptest.NewServer(handler) t.Cleanup(ts.Close) do := func(method, path, body, key string) (int, string) { t.Helper() req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) if key != "" { req.Header.Set("X-API-Key", key) } req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() b, _ := io.ReadAll(res.Body) return res.StatusCode, string(b) } ok := func(method, path, body string) string { t.Helper() code, out := do(method, path, body, "console-key") if code != http.StatusOK { t.Fatalf("%s %s = %d: %s", method, path, code, out) } return out } // The console's authentication guards the resolver's API too: the // resolver's own key is not accepted here, and no key is not either. for _, key := range []string{"", h.key} { if code, _ := do("GET", "/api/status", "", key); code != http.StatusUnauthorized { t.Errorf("key %q reached the proxy: %d", key, code) } } // With the console's key, the resolver's own data comes through. var status map[string]any if err := json.Unmarshal([]byte(ok("GET", "/api/status", "")), &status); err != nil || status["version"] != "test" { t.Fatalf("proxied status = %v (%v)", status, err) } // A group saved in the console becomes a ClientGroup, then policy. ok("PUT", "/api/clients", `{"groups":[{"name":"no-loopback","policy":"deny","match":{"cidrs":["127.0.0.0/8"]}}]}`) type clients struct { Groups []struct{ Name, Source string } `json:"groups"` } // Read back at once, before the resolver can have seen it. var view clients if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &view); err != nil || len(view.Groups) != 1 || view.Groups[0].Name != "no-loopback" { t.Fatalf("clients right after save = %+v (%v)", view, err) } eventually(t, "the resolver to apply the saved group", func() bool { return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeRefused }) // A group the console just removed disappears from its own view at once. ok("PUT", "/api/clients", `{"groups":[]}`) view = clients{} if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &view); err != nil || len(view.Groups) != 0 { t.Fatalf("clients after delete = %+v (%v)", view, err) } eventually(t, "the resolver to drop the deleted group", func() bool { return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeSuccess }) // A bad group is refused with a message and changes nothing. if code, out := do("PUT", "/api/clients", `{"groups":[{"name":"x","policy":"maybe","match":{"cidrs":["10.0.0.0/8"]}}]}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "policy") { t.Errorf("bad group = %d %s", code, out) } // A zone built record by record in the console is served by the resolver. ok("POST", "/api/zones/records", `{"zone":"edit.test.","name":"@","type":"NS","value":"ns1.edit.test."}`) ok("POST", "/api/zones/records", `{"zone":"edit.test.","name":"www","type":"A","ttl":60,"value":"10.7.7.7"}`) eventually(t, "the resolver to serve the new zone", func() bool { m := h.query("www.edit.test", dns.TypeA) if len(m.Answer) != 1 { return false } a, isA := m.Answer[0].(*dns.A) return isA && a.A.String() == "10.7.7.7" && m.Authoritative }) // The zone page is told it can edit, and that this zone is editable. var zones struct { Writable bool `json:"writable"` Zones []struct { Origin string `json:"origin"` Managed bool `json:"managed"` } `json:"zones"` } eventually(t, "the zone to appear in the zone view", func() bool { zones.Zones = nil _ = json.Unmarshal([]byte(ok("GET", "/api/zones", "")), &zones) for _, z := range zones.Zones { if z.Origin == "edit.test." { return z.Managed } } return false }) if !zones.Writable { t.Error("zones are not reported writable") } ok("DELETE", "/api/zones?zone=edit.test.", "") eventually(t, "the deleted zone to stop answering", func() bool { m := h.query("www.edit.test", dns.TypeA) return !m.Authoritative }) }