The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
647 lines
24 KiB
Go
647 lines
24 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.
|
|
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", "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
|
|
}
|
|
}
|
|
}
|