A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages, referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
176 lines
6.0 KiB
Go
176 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|