mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 18:13:46 +00:00
205 lines
6.5 KiB
Go
205 lines
6.5 KiB
Go
package analytics
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/MeshTender/MeshTender/internal/config"
|
|
"github.com/MeshTender/MeshTender/internal/web"
|
|
)
|
|
|
|
func testRecorder() *Recorder {
|
|
return New(nil, &config.Config{PrimaryHost: "app.x", RootHost: "x", AuthHost: "auth.x", WWWHost: "www.x"})
|
|
}
|
|
|
|
func TestHandlerRecordsRequest(t *testing.T) {
|
|
rec := testRecorder()
|
|
h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.x/dashboard", nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
select {
|
|
case e := <-rec.ch:
|
|
if e.Surface != "app" || e.Path != "/dashboard" || e.Status != http.StatusCreated || e.Visitor == "" {
|
|
t.Fatalf("event = %+v, want app /dashboard 201 with a visitor hash", e)
|
|
}
|
|
default:
|
|
t.Fatal("expected an event to be enqueued")
|
|
}
|
|
}
|
|
|
|
// TestHandlerRedactsInviteToken confirms the redaction happens on the real
|
|
// recording path (the enqueued event), not just in the helper.
|
|
func TestHandlerRedactsInviteToken(t *testing.T) {
|
|
rec := testRecorder()
|
|
h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.x/invite/S3cr3tShareToken", nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
select {
|
|
case e := <-rec.ch:
|
|
if e.Path != "/invite/:token" {
|
|
t.Fatalf("recorded path = %q, want the token redacted to /invite/:token", e.Path)
|
|
}
|
|
default:
|
|
t.Fatal("expected an event to be enqueued")
|
|
}
|
|
}
|
|
|
|
func TestHandlerSkips(t *testing.T) {
|
|
rec := testRecorder()
|
|
h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
|
|
cases := []struct {
|
|
path, ua string
|
|
}{
|
|
{"/healthz", "Mozilla/5.0"},
|
|
{"/static/app.css", "Mozilla/5.0"},
|
|
{"/repeaters/abc/ws", "Mozilla/5.0"},
|
|
}
|
|
for _, c := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.x"+c.path, nil)
|
|
if c.ua != "" {
|
|
req.Header.Set("User-Agent", c.ua)
|
|
}
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
select {
|
|
case e := <-rec.ch:
|
|
t.Fatalf("path %q ua %q should be skipped, got %+v", c.path, c.ua, e)
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandlerRecordsKind covers the whole point of the kind column: scanners and
|
|
// crawlers reach the store rather than being dropped at the door, but under a
|
|
// kind the dashboard can hold apart from real visits.
|
|
func TestHandlerRecordsKind(t *testing.T) {
|
|
cases := []struct {
|
|
name, path, ua string
|
|
status int
|
|
want string
|
|
}{
|
|
{"visit", "/dashboard", "Mozilla/5.0", http.StatusOK, KindVisit},
|
|
{"probe", "/wp-login.php", "Mozilla/5.0", http.StatusNotFound, KindProbe},
|
|
{"bot", "/orgs", "Googlebot/2.1", http.StatusOK, KindBot},
|
|
{"empty ua is a bot", "/orgs", "", http.StatusOK, KindBot},
|
|
{"broken link", "/orgs/gone", "Mozilla/5.0", http.StatusNotFound, KindNotFound},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rec := testRecorder()
|
|
h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(c.status)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.x"+c.path, nil)
|
|
if c.ua != "" {
|
|
req.Header.Set("User-Agent", c.ua)
|
|
}
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
select {
|
|
case e := <-rec.ch:
|
|
if e.Kind != c.want {
|
|
t.Fatalf("kind = %q, want %q (event %+v)", e.Kind, c.want, e)
|
|
}
|
|
default:
|
|
t.Fatalf("%s was dropped; it should be recorded as %q", c.name, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestKindUsesRedactedPath: classification runs on the path as stored, so a
|
|
// secret in the URL can't reach the classifier's signature lists either.
|
|
func TestKindUsesRedactedPath(t *testing.T) {
|
|
rec := testRecorder()
|
|
h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "nope", http.StatusNotFound)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://app.x/invite/S3cr3tShareToken.php", nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
select {
|
|
case e := <-rec.ch:
|
|
if e.Path != "/invite/:token" {
|
|
t.Fatalf("recorded path = %q, want the token redacted", e.Path)
|
|
}
|
|
if e.Kind != KindNotFound {
|
|
t.Fatalf("kind = %q, want %q — the .php was in the redacted-away token", e.Kind, KindNotFound)
|
|
}
|
|
default:
|
|
t.Fatal("expected an event to be enqueued")
|
|
}
|
|
}
|
|
|
|
func TestSurfaceClassification(t *testing.T) {
|
|
rec := testRecorder()
|
|
for host, want := range map[string]string{
|
|
"app.x": "app", "x": "root", "www.x": "root", "auth.x": "auth", "club.example.com": "custom",
|
|
} {
|
|
if got := rec.surface(host); got != want {
|
|
t.Errorf("surface(%q) = %q, want %q", host, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVisitorRotates confirms the visitor hash is stable within identical inputs
|
|
// but differs by client (so distinct people are counted distinctly).
|
|
func TestVisitorStableAndDistinct(t *testing.T) {
|
|
rec := testRecorder()
|
|
mk := func(ip, ua string) string {
|
|
r := httptest.NewRequest(http.MethodGet, "http://app.x/", nil)
|
|
r.RemoteAddr = ip + ":1234"
|
|
r.Header.Set("User-Agent", ua)
|
|
return rec.visitor(r)
|
|
}
|
|
a1 := mk("1.2.3.4", "UA-A")
|
|
a2 := mk("1.2.3.4", "UA-A")
|
|
b := mk("5.6.7.8", "UA-A")
|
|
if a1 != a2 {
|
|
t.Fatalf("same client should hash the same: %q vs %q", a1, a2)
|
|
}
|
|
if a1 == b {
|
|
t.Fatalf("different clients should hash differently")
|
|
}
|
|
}
|
|
|
|
// TestHandlerSkipsCSPReports: browser-generated violation reports are
|
|
// infrastructure, not visits. Counting them would let one visitor with a noisy
|
|
// extension inflate the traffic figures — an extension can post thousands a day.
|
|
func TestHandlerSkipsCSPReports(t *testing.T) {
|
|
rec := testRecorder()
|
|
h := rec.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "http://app.x"+web.CSPReportPath, nil)
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
select {
|
|
case e := <-rec.ch:
|
|
t.Fatalf("a CSP report was recorded as traffic: %+v", e)
|
|
default:
|
|
}
|
|
|
|
// Guard the guard: the same recorder must still record an ordinary request, or
|
|
// this would pass with the middleware disabled entirely.
|
|
ordinary := httptest.NewRequest(http.MethodGet, "http://app.x/dashboard", nil)
|
|
ordinary.Header.Set("User-Agent", "Mozilla/5.0")
|
|
h.ServeHTTP(httptest.NewRecorder(), ordinary)
|
|
select {
|
|
case <-rec.ch:
|
|
default:
|
|
t.Fatal("the recorder dropped an ordinary request too — the skip test proves nothing")
|
|
}
|
|
}
|