package server import ( "context" "encoding/json" "io" "log/slog" "path/filepath" "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) } func TestStatsExploreAPI(t *testing.T) { ctx := context.Background() ss, err := stats.OpenStore(":memory:") if err != nil { t.Fatal(err) } defer ss.Close() now := time.Now().Unix() b := &stats.Batch{} b.AddQueries([]stats.QueryRow{ {Time: now - 60, Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}, {Time: now - 50, Client: "10.0.0.7", View: "internal", Name: "ads.tracker.test", Type: "A", RCode: "NXDOMAIN", Blocked: "hagezi"}, {Time: now - 40, Client: "198.51.100.9", View: "external", Name: "nope.example.com", Type: "A", RCode: "NXDOMAIN"}, }) if err := ss.Commit(ctx, b); err != nil { t.Fatal(err) } opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour} c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) e := newAPIEnv(t, 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) ex := e.do("GET", "/stats/explore?rcode=NXDOMAIN&since=1h", e.token, nil, 200) if ex["total"].(float64) != 2 || ex["blocked"].(float64) != 1 { t.Errorf("NXDOMAIN: %v", ex) } clients := ex["by"].(map[string]any)["client"].([]any) if len(clients) != 2 { t.Fatalf("clients %v", clients) } named := false for _, c := range clients { m := c.(map[string]any) named = named || m["key"] == "10.0.0.7" && m["name"] == "laptop.example.com" } if !named { t.Errorf("client names %v", clients) } ex = e.do("GET", "/stats/explore?client=10.0.0.7", e.token, nil, 200) recent := ex["recent"].([]any) if ex["total"].(float64) != 2 || ex["client_name"] != "laptop.example.com" || len(recent) != 2 || recent[0].(map[string]any)["client_name"] != "laptop.example.com" { t.Errorf("laptop: %v", ex) } if ex := e.do("GET", "/stats/explore?client=10.0.0.7&views=external", e.token, nil, 200); ex["total"].(float64) != 0 { t.Errorf("laptop in external only: %v", ex["total"]) } // The dashboard reports the databases' size. st := e.do("GET", "/status", e.token, nil, 200) sg, ok := st["storage"].(map[string]any) if !ok || len(sg["databases"].([]any)) == 0 { t.Errorf("storage %v", st["storage"]) } } // /stats/server reports the statistics database's size and the query log. func TestStatsServerDatabase(t *testing.T) { ss, err := stats.OpenStore(filepath.Join(t.TempDir(), "stats.db")) if err != nil { t.Fatal(err) } defer ss.Close() b := &stats.Batch{} b.AddQueries([]stats.QueryRow{{Time: time.Now().Unix(), Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}}) if err := ss.Commit(context.Background(), b); err != nil { t.Fatal(err) } opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour} e := newAPIEnv(t, stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))) sv := e.do("GET", "/stats/server", e.token, nil, 200) db, ok := sv["database"].(map[string]any) if !ok || db["bytes"].(float64) <= 0 || db["query_log_rows"].(float64) != 1 { t.Errorf("database %v", sv["database"]) } }