Files
VeillaxandClaude Sonnet 5.5 b4fd31a182
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s
Add website analytics
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>
2026-10-27 04:22:55 -06:00

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)
}
}