Files
fancy-dns/internal/app/contract_test.go
T
developer 70d85eac59
build / build-push (push) Failing after 25s
Bring the console onto the contract the Go side now pins
internal/app/contract_test.go asserts the exact JSON key set of every admin
endpoint, and the console was reading fields that shape does not contain —
which is how a page can be entirely blank while the Go suite is green. The
types file is rewritten against the canonical shapes and every consumer is
fixed onto it.

The page bugs that came out of it:

The cache "Lookups" column read `count` and "Last seen" read `last_seen`; the
cache tracks a hit counter and a byte count per name and sends `hits`, so
there is no last-seen data to show at all. Lookups now reads hits, Last seen
is gone, and the row's bytes are shown. The "Size" card shows bytes_human,
with size as the entry-budget hint it actually is.

The Sources table read `item.enabled`, `item.rules`, `item.bytes`,
`item.updated_at` and `item.err`; those live under `item.meta`, so the table
was blank and the per-row toggle posted `name: undefined`. The refresh toast
counted the names echoed back and said "every source" for an empty list.

The dashboard read `qps` and `total_queries` where the server sends
`queries_per_sec` and `queries_total`, and rendered a `latency`/`p99` block
the dashboard response never contained. The `Partial<Dashboard>`-and-cast
that papered over this is gone. `blocked_pct` arrives as 0..100 from REST and
0..1 over SSE, so formatPercent is 0..100 only and a new formatFraction
handles the SSE frame.

Logs rendered `qtype` as a string; it is a number, with `qtype_name`
alongside, so there is a formatQtype that prefers the name. The Domains page
read `item.list` where the server sends `source`, and showed an Updated column
with no timestamp behind it.

Upstreams treated `healthcheck_interval` and `breaker_cooldown` as numbers of
seconds; they are duration strings now, so the page parses them like the
bootstrap refresh it already handled and the write payload no longer sends the
runtime health fields the server ignores. A group edit stripped the four
server-owned counters before writing, because config.Group would reject them.

`whoami.can_write` and `whoami.read_only` were fetched and never used, so a
read-only API key still showed every Save control. A single whoami fetch now
lives in a session provider behind the app shell, every write control is
gated on it, and a banner says why when the key is read-only. It fails open:
an unreachable probe must not lock an operator out of their own console.

Two server fixes the console work exposed, both pinned by the contract test:

POST /api/blocklists/refresh with a body that omits `names` answered
{"names": null}, and the console counts the names it gets back to build its
toast — so "refresh everything" said "0 sources". A nil slice now becomes an
empty one and the key set does not depend on the request.

blocklist.SourceMeta.duration was a raw time.Duration, so it went out as a
nanosecond count and the console divided by 1e6 to compensate — the only
duration on the wire that was not a duration string. It is a config.Duration
now, and formatDuration parses it like the others.

The contract test grew to cover what the first pass left out: the domain
check in all three of its shapes (match, miss, and an error delivered as 200),
the log clear, the settings validate in both the valid and the malformed case,
the settings save and reload, and the first SSE frame.
2026-09-27 18:53:43 +00:00

587 lines
22 KiB
Go

package app
import (
"bufio"
"encoding/json"
"io"
"net/http"
"sort"
"strconv"
"strings"
"testing"
)
// 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.
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
}
}
}