Files
nginx-logtail/cmd/frontend/frontend_test.go
2026-03-14 20:42:51 +01:00

533 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"html/template"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
pb "git.ipng.ch/ipng/nginx-logtail/proto/logtailpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// --- Fake gRPC server ---
type fakeServer struct {
pb.UnimplementedLogtailServiceServer
topNResp *pb.TopNResponse
trendResp *pb.TrendResponse
// Captured for inspection.
lastTopN *pb.TopNRequest
lastTrend *pb.TrendRequest
}
func (f *fakeServer) TopN(_ context.Context, req *pb.TopNRequest) (*pb.TopNResponse, error) {
f.lastTopN = req
if f.topNResp == nil {
return &pb.TopNResponse{}, nil
}
return f.topNResp, nil
}
func (f *fakeServer) Trend(_ context.Context, req *pb.TrendRequest) (*pb.TrendResponse, error) {
f.lastTrend = req
if f.trendResp == nil {
return &pb.TrendResponse{}, nil
}
return f.trendResp, nil
}
func startFake(t *testing.T, fs *fakeServer) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := grpc.NewServer()
pb.RegisterLogtailServiceServer(srv, fs)
go srv.Serve(lis)
t.Cleanup(srv.GracefulStop)
return lis.Addr().String()
}
func mustLoadTemplate(t *testing.T) *template.Template {
t.Helper()
funcMap := template.FuncMap{"fmtCount": fmtCount}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html")
if err != nil {
t.Fatal(err)
}
return tmpl
}
func newHandler(t *testing.T, target string) *Handler {
t.Helper()
return &Handler{
defaultTarget: target,
defaultN: 25,
refreshSecs: 30,
tmpl: mustLoadTemplate(t),
}
}
func get(t *testing.T, h http.Handler, path string) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Body)
return rec.Code, string(body)
}
// --- Unit tests ---
func TestParseWindowString(t *testing.T) {
cases := []struct {
in string
wantPB pb.Window
wantStr string
}{
{"1m", pb.Window_W1M, "1m"},
{"5m", pb.Window_W5M, "5m"},
{"15m", pb.Window_W15M, "15m"},
{"60m", pb.Window_W60M, "60m"},
{"6h", pb.Window_W6H, "6h"},
{"24h", pb.Window_W24H, "24h"},
{"bad", pb.Window_W5M, "5m"}, // default
{"", pb.Window_W5M, "5m"},
}
for _, c := range cases {
w, s := parseWindowString(c.in)
if w != c.wantPB || s != c.wantStr {
t.Errorf("parseWindowString(%q) = (%v, %q), want (%v, %q)", c.in, w, s, c.wantPB, c.wantStr)
}
}
}
func TestParseGroupByString(t *testing.T) {
cases := []struct {
in string
wantPB pb.GroupBy
wantStr string
}{
{"website", pb.GroupBy_WEBSITE, "website"},
{"prefix", pb.GroupBy_CLIENT_PREFIX, "prefix"},
{"uri", pb.GroupBy_REQUEST_URI, "uri"},
{"status", pb.GroupBy_HTTP_RESPONSE, "status"},
{"bad", pb.GroupBy_WEBSITE, "website"}, // default
}
for _, c := range cases {
g, s := parseGroupByString(c.in)
if g != c.wantPB || s != c.wantStr {
t.Errorf("parseGroupByString(%q) = (%v, %q), want (%v, %q)", c.in, g, s, c.wantPB, c.wantStr)
}
}
}
func TestParseQueryParams(t *testing.T) {
h := &Handler{defaultTarget: "default:9091", defaultN: 25}
req := httptest.NewRequest("GET",
"/?target=other:9090&w=60m&by=prefix&n=10&f_website=example.com&f_status=429", nil)
p := h.parseParams(req)
if p.Target != "other:9090" {
t.Errorf("target = %q", p.Target)
}
if p.WindowS != "60m" || p.Window != pb.Window_W60M {
t.Errorf("window = %q / %v", p.WindowS, p.Window)
}
if p.GroupByS != "prefix" || p.GroupBy != pb.GroupBy_CLIENT_PREFIX {
t.Errorf("group-by = %q / %v", p.GroupByS, p.GroupBy)
}
if p.N != 10 {
t.Errorf("n = %d", p.N)
}
if p.Filter.Website != "example.com" {
t.Errorf("f_website = %q", p.Filter.Website)
}
if p.Filter.Status != "429" {
t.Errorf("f_status = %q", p.Filter.Status)
}
}
func TestParseQueryParamsDefaults(t *testing.T) {
h := &Handler{defaultTarget: "agg:9091", defaultN: 20}
req := httptest.NewRequest("GET", "/", nil)
p := h.parseParams(req)
if p.Target != "agg:9091" {
t.Errorf("default target = %q", p.Target)
}
if p.WindowS != "5m" {
t.Errorf("default window = %q", p.WindowS)
}
if p.GroupByS != "website" {
t.Errorf("default group-by = %q", p.GroupByS)
}
if p.N != 20 {
t.Errorf("default n = %d", p.N)
}
}
func TestBuildFilter(t *testing.T) {
f := buildFilter(filterState{Website: "example.com", Status: "404"})
if f == nil {
t.Fatal("expected non-nil filter")
}
if f.GetWebsite() != "example.com" {
t.Errorf("website = %q", f.GetWebsite())
}
if f.GetHttpResponse() != 404 {
t.Errorf("status = %d", f.GetHttpResponse())
}
if f.ClientPrefix != nil || f.HttpRequestUri != nil {
t.Error("unexpected non-nil fields")
}
}
func TestBuildFilterNil(t *testing.T) {
if buildFilter(filterState{}) != nil {
t.Error("expected nil when no filter set")
}
}
func TestDrillURL(t *testing.T) {
p := QueryParams{Target: "agg:9091", WindowS: "5m", GroupByS: "website", N: 25}
u := p.drillURL("example.com")
if !strings.Contains(u, "f_website=example.com") {
t.Errorf("drill from website: missing f_website in %q", u)
}
if !strings.Contains(u, "by=prefix") {
t.Errorf("drill from website: expected by=prefix in %q", u)
}
p.GroupByS = "prefix"
u = p.drillURL("1.2.3.0/24")
if !strings.Contains(u, "by=uri") {
t.Errorf("drill from prefix: expected by=uri in %q", u)
}
p.GroupByS = "status"
u = p.drillURL("429")
if !strings.Contains(u, "f_status=429") {
t.Errorf("drill from status: missing f_status in %q", u)
}
if !strings.Contains(u, "by=website") {
t.Errorf("drill from status: expected cycle back to by=website in %q", u)
}
}
func TestBuildCrumbs(t *testing.T) {
p := QueryParams{
Target: "agg:9091",
WindowS: "5m",
GroupByS: "website",
N: 25,
Filter: filterState{Website: "example.com", Status: "429"},
}
crumbs := buildCrumbs(p)
if len(crumbs) != 2 {
t.Fatalf("expected 2 crumbs, got %d", len(crumbs))
}
if crumbs[0].Text != "website=example.com" {
t.Errorf("crumb[0].text = %q", crumbs[0].Text)
}
// RemoveURL for website crumb should not contain f_website.
if strings.Contains(crumbs[0].RemoveURL, "f_website") {
t.Errorf("remove URL still has f_website: %q", crumbs[0].RemoveURL)
}
// RemoveURL for website crumb should still contain f_status.
if !strings.Contains(crumbs[0].RemoveURL, "f_status=429") {
t.Errorf("remove URL missing f_status: %q", crumbs[0].RemoveURL)
}
}
func TestRenderSparkline(t *testing.T) {
points := []*pb.TrendPoint{
{TimestampUnix: 1, Count: 100},
{TimestampUnix: 2, Count: 200},
{TimestampUnix: 3, Count: 150},
{TimestampUnix: 4, Count: 50},
{TimestampUnix: 5, Count: 300},
}
svg := string(renderSparkline(points))
if !strings.Contains(svg, "<svg") {
t.Error("expected <svg tag")
}
if !strings.Contains(svg, "<polyline") {
t.Error("expected <polyline tag")
}
if !strings.Contains(svg, "points=") {
t.Error("expected points= attribute")
}
}
func TestRenderSparklineTooFewPoints(t *testing.T) {
if renderSparkline(nil) != "" {
t.Error("nil → expected empty")
}
if renderSparkline([]*pb.TrendPoint{{Count: 100}}) != "" {
t.Error("1 point → expected empty")
}
}
func TestRenderSparklineAllZero(t *testing.T) {
points := []*pb.TrendPoint{{Count: 0}, {Count: 0}, {Count: 0}}
if renderSparkline(points) != "" {
t.Error("all-zero → expected empty")
}
}
func TestFmtCount(t *testing.T) {
cases := []struct {
n int64
want string
}{
{0, "0"},
{999, "999"},
{1000, "1 000"},
{18432, "18 432"},
{1234567, "1 234 567"},
}
for _, c := range cases {
if got := fmtCount(c.n); got != c.want {
t.Errorf("fmtCount(%d) = %q, want %q", c.n, got, c.want)
}
}
}
// --- Handler integration tests ---
func TestHandlerTopN(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{
Source: "col-1",
Entries: []*pb.TopNEntry{
{Label: "busy.com", Count: 18432},
{Label: "quiet.com", Count: 100},
},
},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
code, body := get(t, h, "/?target="+addr)
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "busy.com") {
t.Error("expected busy.com in body")
}
if !strings.Contains(body, "quiet.com") {
t.Error("expected quiet.com in body")
}
if !strings.Contains(body, "18 432") {
t.Error("expected formatted count 18 432 in body")
}
}
func TestHandlerRaw(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{
Source: "agg",
Entries: []*pb.TopNEntry{{Label: "x.com", Count: 42}},
},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
code, body := get(t, h, "/?target="+addr+"&raw=1&w=15m&by=prefix")
if code != 200 {
t.Fatalf("status = %d", code)
}
var result struct {
Source string `json:"source"`
Window string `json:"window"`
GroupBy string `json:"group_by"`
Entries []struct {
Label string `json:"label"`
Count int64 `json:"count"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("JSON parse: %v\nbody: %s", err, body)
}
if result.Source != "agg" {
t.Errorf("source = %q", result.Source)
}
if result.Window != "15m" {
t.Errorf("window = %q", result.Window)
}
if result.GroupBy != "prefix" {
t.Errorf("group_by = %q", result.GroupBy)
}
if len(result.Entries) != 1 || result.Entries[0].Label != "x.com" {
t.Errorf("entries = %+v", result.Entries)
}
}
func TestHandlerBadTarget(t *testing.T) {
h := newHandler(t, "127.0.0.1:1") // always refused
code, body := get(t, h, "/?target=127.0.0.1:1")
if code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", code)
}
if !strings.Contains(body, "error") && !strings.Contains(body, "Error") {
t.Error("expected error message in body")
}
}
func TestHandlerFilterPassedToServer(t *testing.T) {
fake := &fakeServer{}
addr := startFake(t, fake)
h := newHandler(t, addr)
get(t, h, "/?target="+addr+"&f_website=example.com&f_status=429")
if fake.lastTopN == nil {
t.Fatal("no TopN request received")
}
f := fake.lastTopN.Filter
if f == nil {
t.Fatal("filter is nil")
}
if f.GetWebsite() != "example.com" {
t.Errorf("website = %q", f.GetWebsite())
}
if f.GetHttpResponse() != 429 {
t.Errorf("status = %d", f.GetHttpResponse())
}
}
func TestHandlerWindowPassedToServer(t *testing.T) {
fake := &fakeServer{}
addr := startFake(t, fake)
h := newHandler(t, addr)
get(t, h, "/?target="+addr+"&w=60m")
if fake.lastTopN == nil {
t.Fatal("no TopN request received")
}
if fake.lastTopN.Window != pb.Window_W60M {
t.Errorf("window = %v, want W60M", fake.lastTopN.Window)
}
}
func TestHandlerBreadcrumbInHTML(t *testing.T) {
fake := &fakeServer{topNResp: &pb.TopNResponse{}}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr+"&f_website=example.com")
if !strings.Contains(body, "website=example.com") {
t.Error("expected breadcrumb with website=example.com")
}
// Remove link should exist.
if !strings.Contains(body, "×") {
t.Error("expected × remove link in breadcrumb")
}
}
func TestHandlerSparklineInHTML(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{Entries: []*pb.TopNEntry{{Label: "x.com", Count: 1}}},
trendResp: &pb.TrendResponse{Points: []*pb.TrendPoint{
{TimestampUnix: 1, Count: 100},
{TimestampUnix: 2, Count: 200},
{TimestampUnix: 3, Count: 150},
}},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr)
if !strings.Contains(body, "<svg") {
t.Error("expected SVG sparkline in body")
}
if !strings.Contains(body, "<polyline") {
t.Error("expected polyline in SVG sparkline")
}
}
func TestHandlerPctBar(t *testing.T) {
fake := &fakeServer{
topNResp: &pb.TopNResponse{
Entries: []*pb.TopNEntry{
{Label: "top.com", Count: 1000},
{Label: "half.com", Count: 500},
},
},
}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr)
if !strings.Contains(body, "<meter") {
t.Error("expected <meter bar in body")
}
// top.com should be 100%, half.com 50%.
if !strings.Contains(body, `value="100"`) {
t.Error("expected 100% bar for top entry")
}
if !strings.Contains(body, `value="50"`) {
t.Error("expected 50% bar for half entry")
}
}
func TestHandlerWindowTabsInHTML(t *testing.T) {
fake := &fakeServer{}
addr := startFake(t, fake)
h := newHandler(t, addr)
_, body := get(t, h, "/?target="+addr+"&w=15m")
// All window labels should appear.
for _, w := range []string{"1m", "5m", "15m", "60m", "6h", "24h"} {
if !strings.Contains(body, ">"+w+"<") {
t.Errorf("expected window tab %q in body", w)
}
}
}
func TestHandlerNoData(t *testing.T) {
fake := &fakeServer{topNResp: &pb.TopNResponse{}}
addr := startFake(t, fake)
h := newHandler(t, addr)
code, body := get(t, h, "/?target="+addr)
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "no data") {
t.Error("expected 'no data' message in body")
}
}
// Verify the fake gRPC server is reachable (sanity check for test infrastructure).
func TestDialFake(t *testing.T) {
addr := startFake(t, &fakeServer{})
conn, client, err := dial(addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = client
// If we got here without error, the fake server is reachable.
_ = grpc.NewClient // suppress unused import warning in case of refactor
_ = insecure.NewCredentials
}