The resolver kept its own query-log store (a bbolt ring, a live ring with SSE fanout, rolling per-domain counters and dashboard time-series) for the console. In a multi-node deployment each pod held a different slice of that, so every view disagreed with itself, and the cost sat on the query path. Charts belong in Grafana and log search in the log collector, so the store is gone. internal/accesslog writes one JSON line per notable query to stdout. The default is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped, refused); log_cached and sample_rate add ordinary answers for a debugging session. Lines go through a bounded buffer to a single writer goroutine and are dropped, and counted in fancydns_access_log_dropped_total, when the stream cannot keep up, so a stalled log pipe can never stall a query. The config key is access_log (the old qlog block is ignored, like any unknown key). The metrics gain what the dashboard used to compute: fancydns_group_queries_total by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear, /api/cache/top, the series rings, and the cache's per-name hit table, which took a mutex and built a string on every cache hit. Tests that read the old log now read the access-log lines; the contract test drops the removed endpoints. The web console still calls them and is changed in the next commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
556 lines
21 KiB
Go
556 lines
21 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("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("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("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("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"))
|
|
})
|
|
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|