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