Files
adamandClaude Opus 5.5 020223d1a5 Show the Name servers check as it happens, and keep its results
The zone's Name servers tab no longer waits for the other servers before
showing: it lists each query as a dig command line with a waiting
counter, fills in the answers as they arrive, then shows the result with
the queries under "Queries sent". Results (serials and delegation) are
kept for 10 minutes so revisiting the tab is instant; "Check again" asks
anew, and a check keeps running when the tab is left.

GET /zones/{zone}/serials?stream=1 streams the queries as
newline-delimited JSON (nscheck.Checker.Watch); without it the endpoint
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:49:11 +00:00

130 lines
4.9 KiB
Go

package server
import (
"encoding/json"
"net/netip"
"strings"
"testing"
"time"
"jarl/internal/nscheck"
)
func TestZoneSerialsTargets(t *testing.T) {
e := newAPIEnv(t)
// Nothing listens on these addresses' DNS port: every server is
// "unreachable" quickly, and the test checks who is asked.
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }}
e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
e.do("POST", "/options", e.token, map[string]any{"scope": "toplevel", "name": "remote-servers", "value": "upstream port 5353 { 127.0.0.31; 127.0.0.32 key xfer; }"}, 201)
e.do("POST", "/zones", e.token, map[string]any{
"name": "example.com",
"nameservers": []string{"ns1.example.com.", "ns2.example.com."},
"records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}},
"options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.23; 127.0.0.24 port 5300 key xfer; }"}},
}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ upstream; 127.0.0.33; }"}}}, 201)
type row struct{ source, status, port string }
servers := func(zone string) map[string]row {
out := map[string]row{}
for _, s := range e.do("GET", "/zones/"+zone+"/serials", e.token, nil, 200)["servers"].([]any) {
m := s.(map[string]any)
port, _ := m["port"].(string)
out[m["address"].(string)] = row{m["source"].(string), m["status"].(string), port}
}
return out
}
got := servers("example.com")
want := map[string]row{
"127.0.0.21": {"NS record", "self", ""},
"127.0.0.22": {"NS record", "unreachable", ""},
"127.0.0.23": {"also-notify", "unreachable", ""},
"127.0.0.24": {"also-notify", "unreachable", "5300"},
}
if len(got) != len(want) {
t.Errorf("primary zone: %v", got)
}
for a, w := range want {
if got[a] != w {
t.Errorf("primary zone, %s: %+v, want %+v", a, got[a], w)
}
}
got = servers("copy.example")
want = map[string]row{
"127.0.0.31": {"primaries", "unreachable", "5353"},
"127.0.0.32": {"primaries", "unreachable", "5353"},
"127.0.0.33": {"primaries", "unreachable", ""},
}
if len(got) != len(want) {
t.Errorf("secondary zone: %v", got)
}
for a, w := range want {
if got[a] != w {
t.Errorf("secondary zone, %s: %+v, want %+v", a, got[a], w)
}
}
// Zone types without serials, and the delegation check's limits.
e.do("POST", "/zones", e.token, map[string]any{"name": "fwd.example", "type": "forward", "options": []map[string]any{{"name": "forwarders", "value": "{ 192.0.2.1; }"}}}, 201)
e.do("GET", "/zones/fwd.example/serials", e.token, nil, 400)
e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400)
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400)
}
func TestZoneSerialsStream(t *testing.T) {
e := newAPIEnv(t)
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }}
e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
e.do("POST", "/zones", e.token, map[string]any{
"name": "example.com",
"nameservers": []string{"ns1.example.com.", "ns2.example.com."},
"records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}},
"options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.24 port 5300 key xfer; }"}},
}, 201)
body, status := e.req("GET", "/zones/example.com/serials?stream=1", e.token, nil)
if status != 200 {
t.Fatalf("stream = %d: %s", status, body)
}
var starts, ends []map[string]any
var done map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
var ev map[string]any
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("line %q: %v", line, err)
}
switch ev["kind"] {
case "start":
starts = append(starts, ev)
case "end":
ends = append(ends, ev)
case "done":
done = ev
}
}
// .21 is this server (not asked); .22 and .24 are.
queries := map[string]bool{}
for _, s := range starts {
queries[s["query"].(string)] = true
}
for _, q := range []string{
"dig @127.0.0.22 -p 1 example.com. SOA +norec",
"dig @127.0.0.24 -p 5300 example.com. SOA +norec -y hmac-sha256:xfer:…",
} {
if !queries[q] {
t.Errorf("no query %q in %v", q, queries)
}
}
if len(starts) != 2 || len(ends) != 2 {
t.Errorf("starts %d, ends %d", len(starts), len(ends))
}
for _, e := range ends {
if r, _ := e["result"].(map[string]any); r == nil || r["status"] != "unreachable" {
t.Errorf("end event %v", e)
}
}
if done == nil || len(done["response"].(map[string]any)["servers"].([]any)) != 3 {
t.Errorf("done = %v", done)
}
}