package sandbar import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) const ( uaChrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" uaPhone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" ) func TestParseUA(t *testing.T) { for _, c := range []struct{ ua, browser, os, device string }{ {uaChrome, "Chrome", "Windows", "Desktop"}, {uaPhone, "Safari", "iOS", "Mobile"}, {"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", "Firefox", "Linux", "Desktop"}, {"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0", "Edge", "Windows", "Desktop"}, {"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36", "Chrome", "Android", "Mobile"}, {"Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 Chrome/126.0 Safari/537.36", "Chrome", "Android", "Tablet"}, } { b, o, d := parseUA(c.ua) if b != c.browser || o != c.os || d != c.device { t.Errorf("%q: got %s/%s/%s, want %s/%s/%s", c.ua, b, o, d, c.browser, c.os, c.device) } } } func TestWebsiteAnalytics(t *testing.T) { s, _, _ := newTestServer(t) s.st.put("website", "w1", &Website{ID: "w1", Name: "Blog", Domain: "example.com"}) ts := httptest.NewServer(s.Handler()) defer ts.Close() send := func(ua string, body string) int { req, _ := http.NewRequest("POST", ts.URL+"/api/send", strings.NewReader(body)) req.Header.Set("Content-Type", "text/plain") req.Header.Set("User-Agent", ua) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } view := func(ua, path, ref string) { b, _ := json.Marshal(map[string]string{"website": "w1", "hostname": "www.example.com", "path": path, "referrer": ref, "language": "en-GB"}) if code := send(ua, string(b)); code != 204 { t.Fatalf("send: %d", code) } } view(uaChrome, "/", "https://news.example.org/story?id=1") view(uaChrome, "/about?utm=x", "") view(uaPhone, "/", "") send(uaChrome, `{"website":"w1","hostname":"example.com","path":"/pricing","event":"signup","data":{"plan":"pro"}}`) // Things that must not be counted. send("Googlebot/2.1", `{"website":"w1","hostname":"example.com","path":"/bot"}`) send(uaChrome, `{"website":"w1","hostname":"evil.test","path":"/wrong-host"}`) send(uaChrome, `{"website":"nope","hostname":"example.com","path":"/unknown-site"}`) send(uaChrome, `not json`) c := newAPIClient(t, ts) if code := c.do("GET", "/api/v1/websites/w1/stats", nil, nil); code != 401 { t.Fatalf("stats without sign-in: %d", code) } c.setup() var st siteStats if code := c.do("GET", "/api/v1/websites/w1/stats?range=7d", nil, &st); code != 200 { t.Fatalf("stats: %d", code) } if st.Views != 3 || st.Visitors != 2 || st.Visits != 2 || st.BounceRate != 50 { t.Fatalf("totals: views %d visitors %d visits %d bounce %.0f", st.Views, st.Visitors, st.Visits, st.BounceRate) } has := func(rows []topRow, name string, views int) bool { for _, r := range rows { if r.Name == name && r.Views == views { return true } } return false } for _, c := range []struct { what string rows []topRow name string n int }{ {"page /", st.Pages, "/", 2}, {"page /about without the query", st.Pages, "/about", 1}, {"referrer host only", st.Referrers, "news.example.org", 1}, {"direct", st.Referrers, "Direct", 2}, {"browser", st.Browsers, "Chrome", 2}, {"os", st.Systems, "iOS", 1}, {"device", st.Devices, "Mobile", 1}, {"language", st.Languages, "en", 3}, {"event", st.Events, "signup", 1}, } { if !has(c.rows, c.name, c.n) { t.Errorf("%s: %q x%d not in %+v", c.what, c.name, c.n, c.rows) } } for _, p := range st.Pages { if p.Name == "/bot" || p.Name == "/wrong-host" || p.Name == "/unknown-site" || p.Name == "/pricing" { t.Errorf("counted something it should not: %+v", p) } } if st.Online != 2 { t.Fatalf("online %d", st.Online) } nonEmpty := 0 for _, p := range st.Series { if p.Views > 0 { nonEmpty++ } } if nonEmpty != 1 || len(st.Series) < 7 { t.Fatalf("series: %d points, %d with views", len(st.Series), nonEmpty) } // No IP address or user agent is stored. var n int s.st.db.QueryRow(`SELECT count(*) FROM hits WHERE visitor LIKE '%127.0.0.1%' OR browser LIKE '%Mozilla%'`).Scan(&n) if n != 0 { t.Fatal("raw identifiers stored") } // The script and the browser preflight. resp, _ := http.Get(ts.URL + "/script.js") body := make([]byte, 64) resp.Body.Read(body) resp.Body.Close() if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Type"), "javascript") { t.Fatalf("script.js: %d %s", resp.StatusCode, resp.Header.Get("Content-Type")) } req, _ := http.NewRequest("OPTIONS", ts.URL+"/api/send", nil) resp, _ = http.DefaultClient.Do(req) resp.Body.Close() if resp.StatusCode != 204 || resp.Header.Get("Access-Control-Allow-Origin") != "*" { t.Fatalf("preflight: %d", resp.StatusCode) } // Deleting the website deletes its hits. if code := c.do("DELETE", "/api/v1/websites/w1", nil, nil); code != 200 { t.Fatalf("delete: %d", code) } s.st.db.QueryRow(`SELECT count(*) FROM hits`).Scan(&n) if n != 0 { t.Fatalf("%d hits left after deleting the website", n) } } func TestHitRateLimitAndProxy(t *testing.T) { s, _, _ := newTestServer(t) for i := 0; i < 120; i++ { if !s.hitAllowed("1.2.3.4") { t.Fatalf("hit %d refused too early", i) } } if s.hitAllowed("1.2.3.4") { t.Fatal("the 121st hit in a minute should be refused") } if !s.hitAllowed("5.6.7.8") { t.Fatal("another address has its own allowance") } r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = "10.0.0.1:5555" r.Header.Set("X-Forwarded-For", "6.6.6.6, 9.9.9.9") if ip := s.realIP(r); ip != "10.0.0.1" { t.Fatalf("without TrustProxy the connection address is used, got %s", ip) } s.opts.TrustProxy = true if ip := s.realIP(r); ip != "9.9.9.9" { t.Fatalf("the proxy's entry is the last one, got %s", ip) } }