Files
fancy-dns/internal/app/contract_test.go
T
developer 504512f247 Add a Zones console: create and manage local zones, records and delegations
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.
2026-09-28 07:45:16 +00:00

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
}
}
}