package edge
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/carlosframework/platform/internal/analytics"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
type memAnalytics struct {
mu sync.Mutex
obs []analytics.Obs
}
func (m *memAnalytics) Observe(o analytics.Obs) { m.mu.Lock(); m.obs = append(m.obs, o); m.mu.Unlock() }
func (m *memAnalytics) snap() []analytics.Obs {
m.mu.Lock()
defer m.mu.Unlock()
return append([]analytics.Obs(nil), m.obs...)
}
func TestClientIP(t *testing.T) {
r := httptest.NewRequest("GET", "http://h/", nil)
r.RemoteAddr = "203.0.113.5:4444"
r.Header.Set("X-Forwarded-For", "198.51.100.1")
if ip := clientIP(r); ip != "203.0.113.5" {
t.Fatalf("unverified: %q", ip)
}
r2 := httptest.NewRequest("GET", "http://h/", nil)
r2 = r2.WithContext(withHopVerified(r2.Context()))
r2.RemoteAddr = "203.0.113.5:4444"
r2.Header.Set("X-Forwarded-For", "10.0.0.1, 198.51.100.7")
if ip := clientIP(r2); ip != "198.51.100.7" {
t.Fatalf("verified hop: %q", ip)
}
}
func TestEdgeObservesOnlyWhenSwitchedOn(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("
hi
"))
}))
t.Cleanup(backend.Close)
reg := openReg(t)
rt := registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web"}
if err := reg.Upsert(rt); err != nil {
t.Fatal(err)
}
e, err := New(reg, nil)
if err != nil {
t.Fatal(err)
}
m := &memAnalytics{}
e.EnableAnalytics(m)
srv := httptest.NewServer(e)
t.Cleanup(srv.Close)
get := func(path string, hdr map[string]string) {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
req.Host = "app.example.com"
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
get("/", nil)
if len(m.snap()) != 0 {
t.Fatal("nothing is observed while the app is off")
}
on := map[string]bool{release.OpsAccount + "/web": true}
e.SetAnalyticsOn(on)
// The switch map is copied, so a caller may keep writing to its own:
// the edge reads its copy from every serving goroutine without a lock,
// and a shared map would be a fatal concurrent map read-and-write.
delete(on, release.OpsAccount+"/web")
on["scribble"] = true
get("/pricing?x=1", map[string]string{"Referer": "https://news.ycombinator.com/item?id=1", "User-Agent": "Mozilla/5.0 Safari", "Sec-Fetch-Dest": "document"})
obs := m.snap()
if len(obs) != 1 {
t.Fatalf("observed %d", len(obs))
}
o := obs[0]
if o.Acct != release.OpsAccount || o.App != "web" || o.Host != "app.example.com" || o.Method != "GET" || o.Status != 200 ||
o.ContentType != "text/html; charset=utf-8" || o.Path != "/pricing" || o.RefererHost != "news.ycombinator.com" ||
o.FetchDest != "document" || o.Bytes == 0 || o.ClientIP == "" || o.When.IsZero() {
t.Fatalf("obs = %+v", o)
}
get("/x", map[string]string{"Purpose": "prefetch"})
if !m.snap()[1].Prefetch {
t.Fatal("prefetch header must be carried")
}
// A speculation-rules prerender arrives as Sec-Purpose:
// prefetch;prerender with Sec-Fetch-Dest: document — a pageview
// nobody looked at unless the first token is matched.
get("/y", map[string]string{"Sec-Purpose": "prefetch;prerender", "Sec-Fetch-Dest": "document"})
if !m.snap()[2].Prefetch {
t.Fatal("prefetch;prerender is a prefetch")
}
e.SetAnalyticsOn(nil)
get("/", nil)
if len(m.snap()) != 3 {
t.Fatal("switching off stops observation")
}
}