Files
jarl/internal/server/stats_test.go
T
adamandClaude Opus 5.5 12820b17bb Statistics per view: toggle views on the Statistics page
Answers, result codes, record types, zones, blocking, rate limiting and the top lists are also counted per view, from named's response log. The Statistics page has a toggle per view with All and None, remembered per browser; jarl stats --view and ?views= on the API. named's own counters stay for the whole server, and the page says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:33 +01:00

124 lines
5.5 KiB
Go

package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"strings"
"testing"
"time"
"jarl/internal/stats"
)
func TestStatsAPIDisabled(t *testing.T) {
e := newAPIEnv(t)
st := e.do("GET", "/stats/status", e.token, nil, 200)
if st["enabled"] != false {
t.Errorf("status = %v", st)
}
out, status := e.req("GET", "/stats/summary", e.token, nil)
if status != 404 || !json.Valid(out) || !strings.Contains(string(out), "stats_disabled") {
t.Errorf("summary while disabled = %d %s", status, out)
}
}
func TestStatsAPI(t *testing.T) {
ctx := context.Background()
ss, err := stats.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer ss.Close()
now := time.Now()
minute := now.Unix() - now.Unix()%60 - 120
hour := minute - minute%3600
b := &stats.Batch{}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
"answer:authoritative": 40}
b.AddCounters(stats.ResMinute, minute, vals)
b.AddCounters(stats.ResHour, hour, vals)
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
b.SetTops(stats.ResHour, hour, stats.KindClient, []stats.Entry{{Key: "10.0.0.7", Count: 50}})
if err := ss.Commit(ctx, b); err != nil {
t.Fatal(err)
}
opts := stats.Options{StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour}
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := newAPIEnv(t, c)
// Some views only.
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
t.Errorf("external summary = %v", sum)
}
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
t.Errorf("rcodes of both views = %v", b)
}
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
t.Errorf("answers per view = %d %s", status, out)
}
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
t.Errorf("status = %v", st)
}
sum := e.do("GET", "/stats/summary?since=1h", e.token, nil, 200)
if sum["queries"].(float64) != 50 || sum["resolution"] != "m" {
t.Errorf("summary = %v", sum)
}
tops := e.list("/stats/top?kind=name&since=2h", e.token)
if len(tops) != 2 || tops[0]["key"] != "www.example.com" {
t.Errorf("top names = %v", tops)
}
// Clients get the host name of Jarl's record for their address.
if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != nil {
t.Errorf("unnamed client = %v", c)
}
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != "laptop.example.com" {
t.Errorf("named client = %v", c)
}
if sum := e.do("GET", "/stats/summary?since=2h", e.token, nil, 200); sum["tops"].(map[string]any)["client"].([]any)[0].(map[string]any)["name"] != "laptop.example.com" {
t.Errorf("summary clients = %v", sum["tops"])
}
if b := e.list("/stats/breakdown?group=zone&since=1h", e.token); len(b) != 2 || b[0]["key"] != "example.com" {
t.Errorf("zones = %v", b)
}
tl := e.do("GET", "/stats/timeline?since=1h&key=queries,rcode:*", e.token, nil, 200)
if tl["step"] != "1m" || len(tl["series"].([]any)) != 3 {
t.Errorf("timeline = %v", tl)
}
if hm := e.do("GET", "/stats/heatmap", e.token, nil, 200); hm["key"] != "queries" {
t.Errorf("heatmap = %v", hm)
}
for _, bad := range []string{"/stats/summary?since=forever", "/stats/top?kind=nope", "/stats/breakdown?group=nope", "/stats/timeline?step=3m"} {
if _, status := e.req("GET", bad, e.token, nil); status != 400 {
t.Errorf("%s = %d, want 400", bad, status)
}
}
// Statistics show client addresses: they need the stats grant.
e.do("POST", "/users", e.token, map[string]any{"username": "editor", "password": "editor-password-1", "role": "dns-editor"}, 201)
var editorID int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "editor" {
editorID = int64(u["id"].(float64))
}
}
tok := e.do("POST", "/users/"+itoa(editorID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
for _, path := range []string{"/stats/status", "/stats/summary", "/stats/top"} {
if _, status := e.req("GET", path, tok, nil); status != 403 {
t.Errorf("dns-editor %s = %d, want 403", path, status)
}
}
e.do("POST", "/users", e.token, map[string]any{"username": "viewer", "password": "viewer-password-1", "role": "readonly"}, 201)
var viewerID int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "viewer" {
viewerID = int64(u["id"].(float64))
}
}
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
e.do("GET", "/stats/summary", vtok, nil, 200)
}