fancy-dns-console is a second binary holding the web UI, authentication and every edit. It writes ClientGroup, BlockList and DNSZone objects through the new kubeedit package and forwards all other /api routes to a running resolver with its own key, dropping the browser's credentials, so it can run in the cluster or on a workstation with a kubeconfig and nothing about the resolver's placement matters. The resolver never imports kubeedit and the console is not on the DNS path. kubeedit validates every spec with the runtime's own converters (kubesource.CheckGroup/CheckList/CheckZone) before writing, so the console cannot store an object the resolver would reject. Zone edits keep the file-based writer's behaviour: duplicate and below-a-delegation records are refused, a delegation in a new zone also gives the zone its servers, and a zone with no apex NS is never stored. Each object records the editing user in an annotation; updates retry on conflict. The resolver reports groups from ClientGroup objects as source "kubernetes" and counter-only rows (the "(default)" bucket, or a group removed since) as "history", and lists DNSZone zones as the editable ones. The console uses that to leave the YAML's groups alone and to drop history rows, which the UI would otherwise send back on save. Its GET /api/clients overlays the cluster's current objects on the resolver's list, because the resolver only sees an edit after its watch fires and a save made just after another would delete the first. An end-to-end test runs both halves against one fake apiserver: a group saved through the console refuses queries, a zone built record by record answers authoritatively, and a request with the resolver's own key is rejected by the console. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
650 lines
25 KiB
Go
650 lines
25 KiB
Go
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
// The contract tests in this file exist because the console's TypeScript types
|
|
// are hand-written and `apiFetch` ends in `JSON.parse(text) as T`, so nothing
|
|
// in the toolchain compared them with the Go structs. A field that lost its
|
|
// json tag, or a response a struct never had, showed up as an empty table in a
|
|
// browser and nowhere else: the Go suite was green and the console was wrong.
|
|
//
|
|
// Each assertion is the EXACT top-level key set of one endpoint, driven
|
|
// through the real router with the real hook implementations (harness_test.go
|
|
// wires a whole App). Renaming a field on either side now fails here with a
|
|
// readable diff instead of in a browser.
|
|
//
|
|
// The sets below are the Go side's truth. Where the console disagrees, the
|
|
// console is what has to move: these are the endpoints a deployed binary
|
|
// answers, and a contract that follows the UI would drag the API backwards
|
|
// every time the UI got nicer. TestConsoleWritePayloadsAreAccepted pins the
|
|
// other direction, because there the console is the client and its payload is
|
|
// fixed.
|
|
|
|
// keysOf returns the sorted top-level keys of a JSON object body.
|
|
func keysOf(t *testing.T, body []byte) []string {
|
|
t.Helper()
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
t.Fatalf("body is not a JSON object: %v\n%s", err, body)
|
|
}
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// keysOfField returns the sorted keys of a nested object addressed by path,
|
|
// where a numeric path element indexes an array.
|
|
func keysOfField(t *testing.T, body []byte, path ...string) []string {
|
|
t.Helper()
|
|
var cur any
|
|
if err := json.Unmarshal(body, &cur); err != nil {
|
|
t.Fatalf("body is not JSON: %v\n%s", err, body)
|
|
}
|
|
for i, p := range path {
|
|
switch node := cur.(type) {
|
|
case map[string]any:
|
|
next, ok := node[p]
|
|
if !ok {
|
|
t.Fatalf("%s: no such key %q at depth %d\n%s", strings.Join(path, "."), p, i, body)
|
|
}
|
|
cur = next
|
|
case []any:
|
|
idx, err := strconv.Atoi(p)
|
|
if err != nil || idx >= len(node) {
|
|
t.Fatalf("%s: %q is not a valid index (len %d)", strings.Join(path, "."), p, len(node))
|
|
}
|
|
cur = node[idx]
|
|
default:
|
|
t.Fatalf("%s: %q is not addressable at depth %d", strings.Join(path, "."), p, i)
|
|
}
|
|
}
|
|
obj, ok := cur.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("%s: is not an object", strings.Join(path, "."))
|
|
}
|
|
out := make([]string, 0, len(obj))
|
|
for k := range obj {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func wantKeys(want ...string) []string {
|
|
out := append([]string(nil), want...)
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func requireKeys(t *testing.T, what string, got, want []string) {
|
|
t.Helper()
|
|
if strings.Join(got, ",") == strings.Join(want, ",") {
|
|
return
|
|
}
|
|
t.Errorf("%s JSON keys drifted.\n got: %s\nwant: %s", what,
|
|
strings.Join(got, ", "), strings.Join(want, ", "))
|
|
t.Log("A key the console does not read is harmless but means the Go side grew " +
|
|
"a field nobody agreed on: put it in web/src/lib/types.ts or take it off " +
|
|
"the wire. A key the console reads and this test does not list is a field " +
|
|
"that works today and will break the console the moment the struct changes.")
|
|
}
|
|
|
|
// TestAdminEndpointKeySets is the table: one entry per admin read endpoint the
|
|
// console calls, with its exact key set.
|
|
func TestAdminEndpointKeySets(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
// One query, so the log and the cache-top tables have a row to shape.
|
|
// Asserting a key set against an empty list proves only the envelope.
|
|
h.query("hot.test.", 1)
|
|
h.query("hot.test.", 1)
|
|
|
|
t.Run("status", func(t *testing.T) {
|
|
code, body := h.get("/api/status")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/status = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/status", keysOf(t, body),
|
|
wantKeys("components", "config", "started", "uptime_s", "version"))
|
|
})
|
|
|
|
t.Run("dashboard", func(t *testing.T) {
|
|
code, body := h.get("/api/dashboard?range=1h")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/dashboard = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/dashboard", keysOf(t, body), wantKeys(
|
|
"blocked_pct", "blocked_total", "blocklist_rules", "cache_entries",
|
|
"cache_hit_rate", "config_version", "degraded_reason", "errors_total",
|
|
"generation", "health", "inflight", "listeners", "node_id",
|
|
"queries_per_sec", "queries_total", "recent", "series", "top_blocked",
|
|
"top_queried", "transports", "upstreams", "uptime_seconds", "version",
|
|
))
|
|
requireKeys(t, "GET /api/dashboard recent[0]", keysOfField(t, body, "recent", "0"), wantKeys(
|
|
"action", "blocked", "client", "client_group", "duration_ms", "id",
|
|
"list", "name", "protocol", "qtype", "qtype_name", "rcode", "rcode_name",
|
|
"response", "ts", "upstream",
|
|
))
|
|
})
|
|
|
|
t.Run("blocklists", func(t *testing.T) {
|
|
code, body := h.get("/api/blocklists")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/blocklists = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/blocklists", keysOf(t, body),
|
|
wantKeys("digest", "errors", "items", "stats"))
|
|
// items[0] is where drift item 1 bit: blocklist.Source had no json
|
|
// tags at all, so the Sources table rendered blank rows and its
|
|
// per-row toggle posted `name: undefined`.
|
|
requireKeys(t, "GET /api/blocklists items[0]", keysOfField(t, body, "items", "0"),
|
|
wantKeys("digest", "format", "meta", "name", "stats"))
|
|
requireKeys(t, "GET /api/blocklists items[0].meta", keysOfField(t, body, "items", "0", "meta"),
|
|
wantKeys("bytes", "duration", "enabled", "err", "etag", "format",
|
|
"http_status", "last_mod", "name", "path", "rules", "stale",
|
|
"updated_at", "url"))
|
|
})
|
|
|
|
// The rules the console's table reads all come from /api/domains, which
|
|
// serves both block and allow rules.
|
|
t.Run("domains", func(t *testing.T) {
|
|
code, body := h.get("/api/domains?per_page=5")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/domains = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/domains", keysOf(t, body),
|
|
wantKeys("items", "page", "per_page", "total", "truncated"))
|
|
requireKeys(t, "GET /api/domains items[0]", keysOfField(t, body, "items", "0"),
|
|
wantKeys("action", "categories", "hits", "name", "rule", "source"))
|
|
})
|
|
|
|
t.Run("clients", func(t *testing.T) {
|
|
code, body := h.get("/api/clients")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/clients = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/clients", keysOf(t, body),
|
|
wantKeys("default_policy", "default_rate_limit", "groups"))
|
|
// Drift item 2: the summary was flat where the console reads `match`,
|
|
// so every group card read "matches nothing" and the editor sent a
|
|
// shape the server refused. `comment` stays omitempty: a group without
|
|
// one must not carry a key the console would have to special-case.
|
|
// `source` is omitted for a group from the config file. It is present
|
|
// here because the queries above made groups[0] the "(default)"
|
|
// traffic bucket, which is reported as source "history".
|
|
requireKeys(t, "GET /api/clients groups[0]", keysOfField(t, body, "groups", "0"),
|
|
wantKeys("allow_domains", "blocked_count", "client_count",
|
|
"deny_domains", "log_queries", "match", "max_concurrent", "matched",
|
|
"name", "policy", "query_count", "rate_limit", "skip_blocklist",
|
|
"skip_cache", "source", "upstreams"))
|
|
requireKeys(t, "GET /api/clients groups[0].match", keysOfField(t, body, "groups", "0", "match"),
|
|
wantKeys("any", "cidrs", "ptr_suffixes", "tags"))
|
|
})
|
|
|
|
t.Run("upstreams", func(t *testing.T) {
|
|
code, body := h.get("/api/upstreams")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/upstreams = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/upstreams", keysOf(t, body), wantKeys(
|
|
"bootstraps", "breaker_cooldown", "breaker_threshold",
|
|
"healthcheck_interval", "max_attempts", "policy", "servers",
|
|
))
|
|
requireKeys(t, "GET /api/upstreams servers[0]", keysOfField(t, body, "servers", "0"),
|
|
wantKeys("addr", "avg_ms", "breaker_open", "fail_rate", "failures",
|
|
"health", "latency_ms", "p95_ms", "proto", "queries", "server_name",
|
|
"weight"))
|
|
})
|
|
|
|
t.Run("logs", func(t *testing.T) {
|
|
code, body := h.get("/api/logs?limit=10")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/logs = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/logs", keysOf(t, body),
|
|
wantKeys("has_more", "items", "limit", "offset", "total"))
|
|
// Every key on every row. These were omitempty, so a row without an
|
|
// ACL group or a blocklist arrived without the key and the console
|
|
// rendered "undefined" instead of an empty cell.
|
|
requireKeys(t, "GET /api/logs items[0]", keysOfField(t, body, "items", "0"), wantKeys(
|
|
"action", "blocked", "client", "client_group", "duration_ms", "id",
|
|
"list", "name", "protocol", "qtype", "qtype_name", "rcode", "rcode_name",
|
|
"response", "ts", "upstream",
|
|
))
|
|
})
|
|
|
|
t.Run("cache", func(t *testing.T) {
|
|
code, body := h.get("/api/cache")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/cache = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/cache", keysOf(t, body), wantKeys(
|
|
"bytes", "bytes_human", "enabled", "entries", "evictions", "hit_rate",
|
|
"hits", "max_ttl_ms", "min_ttl_ms", "misses", "negative_ttl_ms",
|
|
"persisted", "serve_stale_ms", "shards", "size", "stale_hits",
|
|
))
|
|
requireKeys(t, "GET /api/cache persisted", keysOfField(t, body, "persisted"), wantKeys(
|
|
"bytes", "enabled", "entries", "errors", "last_snapshot", "loaded",
|
|
"path", "writes",
|
|
))
|
|
})
|
|
|
|
t.Run("cache top", func(t *testing.T) {
|
|
code, body := h.get("/api/cache/top?limit=5")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/cache/top = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/cache/top", keysOf(t, body), wantKeys("items"))
|
|
// There is no `count` and no `last_seen`: the cache tracks a hit
|
|
// counter and a byte count per name, and the console's "Lookups" and
|
|
// "Last seen" columns were reading fields that never existed.
|
|
requireKeys(t, "GET /api/cache/top items[0]", keysOfField(t, body, "items", "0"),
|
|
wantKeys("bytes", "hits", "name", "qtype", "qtype_name"))
|
|
})
|
|
|
|
t.Run("settings", func(t *testing.T) {
|
|
code, body := h.get("/api/settings")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/settings = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/settings", keysOf(t, body), wantKeys(
|
|
"config_yaml", "defaults_yaml", "effective_yaml", "last_error",
|
|
"path", "reload_available", "version", "writable",
|
|
))
|
|
})
|
|
|
|
t.Run("whoami", func(t *testing.T) {
|
|
code, body := h.get("/api/whoami")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/whoami = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/whoami", keysOf(t, body),
|
|
wantKeys("auth_mode", "base_path", "can_write", "mode", "name", "read_only"))
|
|
})
|
|
}
|
|
|
|
// TestWriteEndpointKeySets pins the small write responses.
|
|
func TestWriteEndpointKeySets(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
t.Run("blocklist refresh", func(t *testing.T) {
|
|
code, body := h.do(http.MethodPost, "/api/blocklists/refresh", `{"names":[]}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("POST /api/blocklists/refresh = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "POST /api/blocklists/refresh", keysOf(t, body), wantKeys("names", "started"))
|
|
// The toast used to say "0 sources queued": names is a slice, and a
|
|
// nil slice marshals to null, which the console counted as zero.
|
|
var got struct {
|
|
Names []string `json:"names"`
|
|
}
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Names == nil {
|
|
t.Errorf("names is null, so the console's source count is a lie: %s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("cache flush", func(t *testing.T) {
|
|
code, body := h.do(http.MethodPost, "/api/cache/flush", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("POST /api/cache/flush = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "POST /api/cache/flush", keysOf(t, body), wantKeys("entries", "ok"))
|
|
})
|
|
|
|
t.Run("acl save", func(t *testing.T) {
|
|
code, body := h.do(http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("PUT /api/clients = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "PUT /api/clients", keysOf(t, body), wantKeys("ok", "version"))
|
|
})
|
|
|
|
t.Run("upstream save", func(t *testing.T) {
|
|
code, body := h.do(http.MethodPut, "/api/upstreams",
|
|
`{"policy":"parallel","servers":[{"addr":"127.0.0.1:5300","proto":"udp","weight":1}],`+
|
|
`"bootstraps":[],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval":"30s"}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("PUT /api/upstreams = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "PUT /api/upstreams", keysOf(t, body), wantKeys("ok", "version"))
|
|
})
|
|
}
|
|
|
|
// TestConsoleWritePayloadsAreAccepted drives the two write endpoints with the
|
|
// exact payload web/src builds. Both answered 400 with `json: unknown field
|
|
// ...`, and no hand-written Go test found it: the suite never sent what the
|
|
// console actually sends.
|
|
func TestConsoleWritePayloadsAreAccepted(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
t.Run("clients", func(t *testing.T) {
|
|
// web/src/pages/Clients.tsx toGroup(): match is nested, and the
|
|
// console sends the ClientGroup shape without the server-owned
|
|
// counters (client_count, query_count, blocked_count, matched).
|
|
const body = `{"groups":[{
|
|
"name":"console",
|
|
"comment":"from the console",
|
|
"policy":"allow",
|
|
"match":{"cidrs":["10.0.0.0/8"],"ptr_suffixes":["lan.example."],"tags":["laptop"],"any":false},
|
|
"allow_domains":["*.example.net"],
|
|
"deny_domains":["*.ads.example.net"],
|
|
"skip_blocklist":false,
|
|
"skip_cache":false,
|
|
"rate_limit":10,
|
|
"max_concurrent":0,
|
|
"log_queries":true,
|
|
"upstreams":[]
|
|
}],"default_policy":"allow"}`
|
|
code, res := h.do(http.MethodPut, "/api/clients", body)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("PUT /api/clients with the console payload = %d: %s", code, res)
|
|
}
|
|
// And it must round-trip: what the console sent comes back in the
|
|
// shape it sent, or the editor empties itself on refetch.
|
|
_, list := h.get("/api/clients")
|
|
var got struct {
|
|
Groups []struct {
|
|
Name string `json:"name"`
|
|
Match struct {
|
|
CIDRs []string `json:"cidrs"`
|
|
Tags []string `json:"tags"`
|
|
} `json:"match"`
|
|
} `json:"groups"`
|
|
}
|
|
if err := json.Unmarshal(list, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Groups) != 1 {
|
|
t.Fatalf("the saved group did not come back: %s", list)
|
|
}
|
|
if got.Groups[0].Name != "console" {
|
|
t.Errorf("group name = %q, want console", got.Groups[0].Name)
|
|
}
|
|
if len(got.Groups[0].Match.CIDRs) != 1 || got.Groups[0].Match.CIDRs[0] != "10.0.0.0/8" {
|
|
t.Errorf("match.cidrs = %v, want [10.0.0.0/8]; the nested shape must survive", got.Groups[0].Match.CIDRs)
|
|
}
|
|
if len(got.Groups[0].Match.Tags) != 1 {
|
|
t.Errorf("match.tags = %v, want [laptop]", got.Groups[0].Match.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("upstreams", func(t *testing.T) {
|
|
// web/src/pages/Upstreams.tsx save(): the console sends the whole
|
|
// server object it read, including the runtime health fields, with a
|
|
// comment saying the server ignores them. Refusing them is drift
|
|
// item 4 — every save in the console answered 400.
|
|
const body = `{"policy":"failover","max_attempts":2,"breaker_threshold":5,
|
|
"healthcheck_interval_s":30,
|
|
"servers":[{"addr":"127.0.0.1:5300","proto":"udp","server_name":"",
|
|
"insecure_skip_verify":false,"weight":1,"comment":"",
|
|
"health":"down","latency_ms":0,"fail_rate":0,"breaker_open":false,
|
|
"queries":0,"avg_ms":0,"p95_ms":0}],
|
|
"bootstraps":[{"name":"quad9","addresses":["9.9.9.9"],"refresh":"24h"}]}`
|
|
code, res := h.do(http.MethodPut, "/api/upstreams", body)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("PUT /api/upstreams with the console payload = %d: %s", code, res)
|
|
}
|
|
// The duration string form must survive the round trip too, or the
|
|
// console's duration inputs show a nanosecond count.
|
|
_, list := h.get("/api/upstreams")
|
|
var got struct {
|
|
HealthCheck json.RawMessage `json:"healthcheck_interval"`
|
|
Cooldown json.RawMessage `json:"breaker_cooldown"`
|
|
Servers []struct {
|
|
Addr string `json:"addr"`
|
|
} `json:"servers"`
|
|
Bootstraps []struct {
|
|
Name string `json:"name"`
|
|
Refresh json.RawMessage `json:"refresh"`
|
|
} `json:"bootstraps"`
|
|
}
|
|
if err := json.Unmarshal(list, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got.HealthCheck) != `"30s"` {
|
|
t.Errorf("healthcheck_interval = %s, want \"30s\"", got.HealthCheck)
|
|
}
|
|
if string(got.Cooldown) == "" {
|
|
t.Error("breaker_cooldown disappeared from the response")
|
|
}
|
|
if len(got.Servers) != 1 || got.Servers[0].Addr != "127.0.0.1:5300" {
|
|
t.Fatalf("servers = %+v, want the saved 127.0.0.1:5300", got.Servers)
|
|
}
|
|
if len(got.Bootstraps) != 1 || got.Bootstraps[0].Name != "quad9" {
|
|
t.Fatalf("bootstraps = %+v, want the saved quad9", got.Bootstraps)
|
|
}
|
|
// Go's canonical spelling of 24h, which is what Duration.MarshalJSON
|
|
// writes and what the console's duration parser accepts.
|
|
if string(got.Bootstraps[0].Refresh) != `"24h0m0s"` {
|
|
t.Errorf("bootstraps[0].refresh = %s, want a duration string", got.Bootstraps[0].Refresh)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRemainingEndpointKeySets covers the endpoints the first pass left out:
|
|
// the domain check (which has three distinct shapes, one of them an error
|
|
// delivered as 200), the log clear, the settings writes and the first SSE
|
|
// frame. A key set the console reads but no test pins is a field that works
|
|
// today and breaks the console the first time somebody tidies a struct.
|
|
func TestRemainingEndpointKeySets(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
t.Run("domain check miss", func(t *testing.T) {
|
|
code, body := h.get("/api/domains/check?name=nothing.example.test")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/domains/check = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/domains/check (no match)", keysOf(t, body),
|
|
wantKeys("action", "matched", "name", "query", "source"))
|
|
})
|
|
|
|
t.Run("domain check match", func(t *testing.T) {
|
|
code, body := h.get("/api/domains/check?name=ads.example.com")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/domains/check = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/domains/check (match)", keysOf(t, body), wantKeys(
|
|
"action", "categories", "hits", "match_kind", "matched",
|
|
"matched_pattern", "name", "query", "rule", "source",
|
|
))
|
|
})
|
|
|
|
t.Run("logs clear", func(t *testing.T) {
|
|
code, body := h.do(http.MethodPost, "/api/logs/clear", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("POST /api/logs/clear = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "POST /api/logs/clear", keysOf(t, body), wantKeys("ok"))
|
|
})
|
|
|
|
t.Run("settings validate", func(t *testing.T) {
|
|
// A document that is valid: the issue lists are present and empty.
|
|
code, body := h.do(http.MethodPost, "/api/settings/validate",
|
|
`{"config_yaml":"listen: 127.0.0.1:5353\n"}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("POST /api/settings/validate = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "POST /api/settings/validate", keysOf(t, body),
|
|
wantKeys("errors", "ok", "warnings"))
|
|
// A malformed document is still a 200 with a populated `errors`: the
|
|
// console renders the field list, so the envelope is the same.
|
|
code, body = h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml":"\t- :\n"}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("POST /api/settings/validate (bad yaml) = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "POST /api/settings/validate (bad yaml)", keysOf(t, body),
|
|
wantKeys("errors", "ok", "warnings"))
|
|
var bad struct {
|
|
Errors []struct{ Field, Message string } `json:"errors"`
|
|
}
|
|
if err := json.Unmarshal(body, &bad); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(bad.Errors) == 0 {
|
|
t.Errorf("a malformed document validated clean: %s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("settings save and reload", func(t *testing.T) {
|
|
doc := h.OriginalConfig()
|
|
code, body := h.do(http.MethodPut, "/api/settings", `{"config_yaml":`+quote(doc)+`}`)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("PUT /api/settings = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "PUT /api/settings", keysOf(t, body), wantKeys("ok", "version", "warnings"))
|
|
code, body = h.do(http.MethodPost, "/api/settings/reload", "")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("POST /api/settings/reload = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "POST /api/settings/reload", keysOf(t, body), wantKeys("ok", "version"))
|
|
})
|
|
|
|
t.Run("log stream ready frame", func(t *testing.T) {
|
|
// The stream never ends, so read the first frame and hang up: the
|
|
// console only needs to know the stream opened and what it carries.
|
|
req, _ := http.NewRequest("GET", "http://"+h.api+"/api/logs/stream", nil)
|
|
req.Header.Set("X-API-Key", h.key)
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET /api/logs/stream: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /api/logs/stream = %d", res.StatusCode)
|
|
}
|
|
br := newLineReader(res.Body)
|
|
var event, data string
|
|
for range 2 {
|
|
line, err := br.readLine()
|
|
if err != nil {
|
|
t.Fatalf("read stream: %v", err)
|
|
}
|
|
switch {
|
|
case line == "":
|
|
case len(line) > 7 && line[:7] == "event: ":
|
|
event = line[7:]
|
|
case len(line) > 6 && line[:6] == "data: ":
|
|
data = line[6:]
|
|
}
|
|
if event != "" && data != "" {
|
|
break
|
|
}
|
|
}
|
|
if event != "ready" {
|
|
t.Fatalf("first event = %q, want ready", event)
|
|
}
|
|
requireKeys(t, "GET /api/logs/stream ready frame", keysOf(t, []byte(data)),
|
|
wantKeys("at", "version"))
|
|
})
|
|
}
|
|
|
|
// quote renders a Go string as a JSON string literal.
|
|
// TestZoneEndpointKeySets pins the zone list and the records inside it.
|
|
//
|
|
// The zone list is the one shape on this side of the API that a test cannot
|
|
// assert from an empty list: an envelope proves nothing about a record, and a
|
|
// record is the thing the console renders row by row. So this builds a zone
|
|
// through the write endpoint, delegates a name in it, and pins every level.
|
|
func TestZoneEndpointKeySets(t *testing.T) {
|
|
managed := filepath.Join(t.TempDir(), "zones")
|
|
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
|
|
|
|
write := func(t *testing.T, method, path, body string) {
|
|
t.Helper()
|
|
code, out := h.do(method, path, body)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("%s %s = %d: %s", method, path, code, out)
|
|
}
|
|
}
|
|
// A zone needs a server list before it can hold anything, and a
|
|
// delegation is the other way to start one.
|
|
write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
|
|
write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
|
|
write(t, http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
|
|
|
|
t.Run("zones", func(t *testing.T) {
|
|
code, body := h.get("/api/zones")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/zones = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/zones", keysOf(t, body),
|
|
wantKeys("built_at", "error", "files", "managed_dir", "records", "types", "writable", "zones"))
|
|
})
|
|
t.Run("zone", func(t *testing.T) {
|
|
code, body := h.get("/api/zones")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/zones = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/zones zones[]", keysOfField(t, body, "zones", "0"),
|
|
wantKeys("default_ttl", "delegations", "files", "managed", "origin", "record_count", "records", "serial", "soa"))
|
|
})
|
|
t.Run("record", func(t *testing.T) {
|
|
code, body := h.get("/api/zones")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/zones = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/zones zones[].records[]", keysOfField(t, body, "zones", "0", "records", "0"),
|
|
wantKeys("delegation", "managed", "name", "source", "ttl", "type", "value", "wildcard"))
|
|
})
|
|
t.Run("delegation", func(t *testing.T) {
|
|
code, body := h.get("/api/zones")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GET /api/zones = %d: %s", code, body)
|
|
}
|
|
requireKeys(t, "GET /api/zones zones[].delegations[]", keysOfField(t, body, "zones", "0", "delegations", "0"),
|
|
wantKeys("managed", "name", "targets"))
|
|
})
|
|
}
|
|
|
|
func quote(s string) string {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// lineReader reads \n-terminated lines off the SSE stream, which is what
|
|
// bufio.Scanner would be for — except Scanner's default 64 KiB cap turns a long
|
|
// data frame into "token too long" and the test would fail on payload size
|
|
// rather than on the contract.
|
|
type lineReader struct {
|
|
r *bufio.Reader
|
|
buf []byte
|
|
}
|
|
|
|
func newLineReader(r io.Reader) *lineReader {
|
|
return &lineReader{r: bufio.NewReader(r), buf: make([]byte, 0, 4096)}
|
|
}
|
|
|
|
func (l *lineReader) readLine() (string, error) {
|
|
l.buf = l.buf[:0]
|
|
for {
|
|
chunk, isPrefix, err := l.r.ReadLine()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
l.buf = append(l.buf, chunk...)
|
|
if !isPrefix {
|
|
return string(l.buf), nil
|
|
}
|
|
}
|
|
}
|