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>
124 lines
5.5 KiB
Go
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)
|
|
}
|