| index b738cb629..70973b56b 100644 |
| --- a/internal/edge/browserdoor_test.go |
| +++ b/internal/edge/browserdoor_test.go |
| @@ -1,12 +1,19 @@ |
| package edge |
| |
| import ( |
| + "io" |
| "net/http" |
| "net/http/httptest" |
| "strings" |
| + "sync" |
| "testing" |
| + "time" |
| |
| + "github.com/carlosframework/platform/internal/analytics" |
| + "github.com/carlosframework/platform/internal/apperr" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| |
| func TestParseErrorBeaconCaps(t *testing.T) { |
| @@ -201,3 +208,310 @@ func TestBeaconOriginHostileInputs(t *testing.T) { |
| t.Error("origin accepted against an empty map") |
| } |
| } |
| + |
| +// --------------------------------------------------------------------------- |
| +// The door handler (spec §3.4): POST /__carlos/e and /__carlos/a. |
| + |
| +// memSink is the error sink the door tests file against — the interface, not |
| +// apperr's buffer, so nothing here depends on the sink's own sanitising. |
| +type memSink struct { |
| + mu sync.Mutex |
| + recs []sinkRec |
| +} |
| + |
| +type sinkRec struct { |
| + Acct, App string |
| + R apperr.Record |
| +} |
| + |
| +func (s *memSink) Add(acct, app string, r apperr.Record) { |
| + s.mu.Lock() |
| + s.recs = append(s.recs, sinkRec{acct, app, r}) |
| + s.mu.Unlock() |
| +} |
| + |
| +// snap copies what has landed. The tests read through this rather than the |
| +// slice: the records are appended on the server's goroutine. |
| +func (s *memSink) snap() []sinkRec { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return append([]sinkRec(nil), s.recs...) |
| +} |
| + |
| +// doorFixture: one counted, door-open app on app.example.com whose upstream |
| +// must never be reached (everything under ReservedPrefix is answered before |
| +// the proxy), a memory sink and a memory meter. |
| +func doorFixture(t *testing.T) (*Edge, *httptest.Server, *memSink, *memAnalytics) { |
| + t.Helper() |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + t.Errorf("upstream reached: %s", r.URL.Path) |
| + })) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), |
| + Status: "ready", App: "web", Version: "v7", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sink, meter := &memSink{}, &memAnalytics{} |
| + e.EnableErrors(sink) |
| + e.EnableAnalytics(meter) |
| + e.SetSwitches(map[string]telemetry.Switches{release.OpsAccount + "/web": {Count: true, Door: true}}) |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return e, srv, sink, meter |
| +} |
| + |
| +// postBeacon posts one beacon body and returns the status. A header named |
| +// with an empty value is DELETED rather than sent empty — the two are |
| +// different requests to the Origin rule, and a test that wants "no Origin" |
| +// must send no Origin at all. |
| +func postBeacon(t *testing.T, srv *httptest.Server, path, body string, hdr map[string]string) int { |
| + t.Helper() |
| + req, err := http.NewRequest("POST", srv.URL+path, strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Host = "app.example.com" |
| + req.Header.Set("Origin", "https://app.example.com") |
| + for k, v := range hdr { |
| + if v == "" { |
| + req.Header.Del(k) |
| + } else { |
| + req.Header.Set(k, v) |
| + } |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if len(b) != 0 { |
| + t.Fatalf("beacon answered with a body: %q", b) |
| + } |
| + return resp.StatusCode |
| +} |
| + |
| +// setPathMap gives host a prefix map through the seam the prefix-routing |
| +// tests already share: one route_paths row per prefix (upsertPath, the shape |
| +// the domains reconcile pass derives from a claim's Paths), then a Reload, |
| +// which is what folds those rows onto the live upstream entries. Nothing here |
| +// pokes e.upstreams directly — the point is to exercise the same map build |
| +// serving uses. |
| +func setPathMap(t *testing.T, e *Edge, host string, m map[string]string) { |
| + t.Helper() |
| + for prefix, target := range m { |
| + upsertPath(t, e.reg, host, prefix, target) |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("reload: %v", err) |
| + } |
| +} |
| + |
| +func TestDoorAcceptsAndAttributes(t *testing.T) { |
| + _, srv, sink, meter := doorFixture(t) |
| + if st := postBeacon(t, srv, "/__carlos/e", `{"kind":"TypeError","name":"x is not a function","stack":"at a (app.js:1:2)","path":"/checkout?step=2","fields":{"user":"a@b.co"}}`, nil); st != 202 { |
| + t.Fatalf("status %d", st) |
| + } |
| + recs := sink.snap() |
| + if len(recs) != 1 { |
| + t.Fatalf("records %d", len(recs)) |
| + } |
| + r := recs[0] |
| + if r.Acct != release.OpsAccount || r.App != "web" || r.R.Source != apperr.SourceBrowser || |
| + r.R.Kind != "TypeError" || r.R.Host != "app.example.com" || r.R.Release != "v7" || |
| + r.R.Path != "/checkout?step=2" { |
| + t.Fatalf("record %+v", r) |
| + } |
| + if r.R.FP != "" || r.R.FPV != 0 { |
| + t.Fatalf("a beacon with no fp must reach the sink unstamped, got fp=%q fpv=%d", r.R.FP, r.R.FPV) |
| + } |
| + if st := postBeacon(t, srv, "/__carlos/a", `{"name":"signup","path":"/"}`, nil); st != 202 { |
| + t.Fatalf("status %d", st) |
| + } |
| + if tr := meter.snapTracks(); len(tr) != 1 || tr[0].Name != "signup" || tr[0].App != "web" || tr[0].Acct != release.OpsAccount { |
| + t.Fatalf("tracks %+v", tr) |
| + } |
| + postBeacon(t, srv, "/__carlos/a", `{"name":"pageview","path":"/"}`, nil) |
| + if len(meter.snapTracks()) != 1 { |
| + t.Fatal("a browser pageview is not counted in edge mode") |
| + } |
| + if len(meter.snapDrops()) != 0 { |
| + t.Fatalf("nothing should be dropped: %+v", meter.snapDrops()) |
| + } |
| + // A browser-supplied fingerprint is carried through WITH its version |
| + // stamp, so the console never groups a v1 hash under an unversioned key. |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n","fp":"deadbeef"}`, nil) |
| + recs = sink.snap() |
| + if last := recs[len(recs)-1]; last.R.FP != "deadbeef" || last.R.FPV != apperr.FPVersion { |
| + t.Fatalf("fp carried as %q/%d, want deadbeef/%d", last.R.FP, last.R.FPV, apperr.FPVersion) |
| + } |
| +} |
| + |
| +func TestDoorDropsAndAlways202(t *testing.T) { |
| + _, srv, sink, meter := doorFixture(t) |
| + cases := []struct { |
| + name, path, body string |
| + hdr map[string]string |
| + door byte |
| + reason string |
| + }{ |
| + {"foreign origin", "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"}, 'e', "origin"}, |
| + {"foreign origin, matching referer", "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net", "Referer": "https://app.example.com/"}, 'e', "origin"}, |
| + {"cross-site", "/__carlos/a", `{"name":"n"}`, map[string]string{"Sec-Fetch-Site": "cross-site"}, 'a', "origin"}, |
| + {"no origin no referer", "/__carlos/a", `{"name":"n"}`, map[string]string{"Origin": ""}, 'a', "origin"}, |
| + {"17 KB", "/__carlos/e", `{"name":"` + strings.Repeat("x", 17<<10) + `"}`, nil, 'e', "size"}, |
| + {"not json", "/__carlos/e", `nope`, nil, 'e', "malformed"}, |
| + {"no name", "/__carlos/a", `{"path":"/"}`, nil, 'a', "malformed"}, |
| + } |
| + for _, c := range cases { |
| + before := len(meter.snapDrops()) |
| + if st := postBeacon(t, srv, c.path, c.body, c.hdr); st != 202 { |
| + t.Errorf("%s: status %d", c.name, st) |
| + } |
| + drops := meter.snapDrops() |
| + if len(drops) != before+1 || drops[before].Door != c.door || drops[before].Reason != c.reason || |
| + drops[before].Acct != release.OpsAccount || drops[before].App != "web" { |
| + t.Errorf("%s: drops %+v", c.name, drops[before:]) |
| + } |
| + } |
| + if len(sink.snap()) != 0 || len(meter.snapTracks()) != 0 { |
| + t.Fatal("dropped beacons must not land") |
| + } |
| + // Missing Origin with a matching Referer counts. |
| + if st := postBeacon(t, srv, "/__carlos/a", `{"name":"ok"}`, map[string]string{"Origin": "", "Referer": "https://app.example.com/p"}); st != 202 || len(meter.snapTracks()) != 1 { |
| + t.Fatal("referer fallback") |
| + } |
| +} |
| + |
| +func TestDoorRateLimit(t *testing.T) { |
| + _, srv, sink, meter := doorFixture(t) |
| + for i := 0; i < doorVisitorPerMinute; i++ { |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil) |
| + } |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil) |
| + if n := len(sink.snap()); n != doorVisitorPerMinute { |
| + t.Fatalf("landed %d", n) |
| + } |
| + if d := meter.snapDrops(); len(d) != 1 || d[0].Reason != "rate" || d[0].Door != 'e' { |
| + t.Fatalf("drops %+v", d) |
| + } |
| + // The visitor key is the address AND the User-Agent, so the same address |
| + // behind another UA is another visitor with a bucket of its own. |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, map[string]string{"User-Agent": "another-agent"}) |
| + if n := len(sink.snap()); n != doorVisitorPerMinute+1 { |
| + t.Fatalf("landed %d — a different User-Agent is a different visitor", n) |
| + } |
| + |
| + // The app bucket. Drained through the limiter with a fresh visitor per |
| + // token rather than over 601 posts: the bucket refills at 10/s, so a |
| + // 601-request loop would only refuse while the loop stayed inside a tenth |
| + // of a second of wall clock. What the DOOR owes is that it consults the |
| + // app bucket under acct+"/"+app for the right door, and that is what this |
| + // pins; TestDoorLimiterAppBucket pins the bucket's own arithmetic on a |
| + // fixed clock. |
| + e2, srv2, sink2, meter2 := doorFixture(t) |
| + key := release.OpsAccount + "/web" |
| + for i := 0; i < doorAppPerMinute; i++ { |
| + if !e2.doorLimiter().allow(key, analytics.DoorEvents, uint64(i), time.Now()) { |
| + t.Fatalf("app bucket refused its own token %d", i) |
| + } |
| + } |
| + postBeacon(t, srv2, "/__carlos/a", `{"name":"n"}`, nil) |
| + if len(meter2.snapTracks()) != 0 { |
| + t.Fatal("an event landed with the app's events bucket empty") |
| + } |
| + if d := meter2.snapDrops(); len(d) != 1 || d[0].Reason != "rate" || d[0].Door != 'a' { |
| + t.Fatalf("drops %+v", d) |
| + } |
| + // The two doors have separate buckets: /e is unaffected. |
| + postBeacon(t, srv2, "/__carlos/e", `{"name":"n"}`, nil) |
| + if n := len(sink2.snap()); n != 1 { |
| + t.Fatalf("errors landed %d — the errors bucket is separate from the events one", n) |
| + } |
| +} |
| + |
| +func TestDoorSwitches(t *testing.T) { |
| + e, srv, sink, meter := doorFixture(t) |
| + key := release.OpsAccount + "/web" |
| + e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}}) |
| + postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil) |
| + if len(meter.snapTracks()) != 0 || len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 { |
| + t.Fatal("count off: /a dropped silently, /e accepted, nothing recorded as a drop") |
| + } |
| + e.SetSwitches(map[string]telemetry.Switches{key: {Count: true, Door: false}}) |
| + postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil) |
| + if len(meter.snapTracks()) != 0 || len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 { |
| + t.Fatal("door off: both dropped silently") |
| + } |
| + // Count off: an origin drop is not recorded either (ruling 2). |
| + e.SetSwitches(map[string]telemetry.Switches{key: {Count: false, Door: true}}) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, map[string]string{"Origin": "https://evil.example.net"}) |
| + if len(meter.snapDrops()) != 0 { |
| + t.Fatal("drops are recorded only for a counted app") |
| + } |
| + // No switch map at all is a closed door, which is every app until the |
| + // telemetry pass pushes one. |
| + e.SetSwitches(nil) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil) |
| + if len(sink.snap()) != 1 || len(meter.snapDrops()) != 0 { |
| + t.Fatal("an unswitched app's door is shut") |
| + } |
| +} |
| + |
| +// TestDoorAttributesByPathPrefix: on a path-routed hostname the beacon lands |
| +// on the app that owns its path, not on the hostname's own route. |
| +func TestDoorAttributesByPathPrefix(t *testing.T) { |
| + e, srv, sink, _ := doorFixture(t) |
| + if err := e.reg.Upsert(registry.Route{ |
| + Host: "team.internal", Kind: "service", Addr: "127.0.0.1:1", Status: "ready", App: "team", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + setPathMap(t, e, "app.example.com", map[string]string{"team": "team.internal"}) |
| + e.SetSwitches(map[string]telemetry.Switches{ |
| + release.OpsAccount + "/web": {Count: true, Door: true}, |
| + release.OpsAccount + "/team": {Count: true, Door: true}, |
| + }) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n","path":"/team/page"}`, nil) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n","path":"/other"}`, nil) |
| + postBeacon(t, srv, "/__carlos/e", `{"name":"n"}`, nil) |
| + recs := sink.snap() |
| + if len(recs) != 3 || recs[0].App != "team" || recs[1].App != "web" || recs[2].App != "web" { |
| + t.Fatalf("attribution %+v", recs) |
| + } |
| + if recs[0].R.Host != "team.internal" { |
| + t.Fatalf("record host %q, want the attributed route's own host", recs[0].R.Host) |
| + } |
| +} |
| + |
| +// TestReservedPathIsUnmetered is §4's other half of the reserved-path ruling, |
| +// and the pin deferred from the intercept task: the dispatch sits before the |
| +// observing writer in ServeHTTP, so neither the snippet nor a beacon may |
| +// produce a usage row for the host. |
| +func TestReservedPathIsUnmetered(t *testing.T) { |
| + e, srv, _, meter := doorFixture(t) |
| + um := NewUsageMeter() |
| + e.EnableUsage(um) |
| + |
| + if code, body := getHostPath(t, srv.URL, "app.example.com", ReservedPrefix+"carlos.js"); code != http.StatusOK || body == "" { |
| + t.Fatalf("carlos.js: status=%d len=%d", code, len(body)) |
| + } |
| + if st := postBeacon(t, srv, "/__carlos/a", `{"name":"n"}`, nil); st != 202 { |
| + t.Fatalf("beacon status %d", st) |
| + } |
| + if len(meter.snapTracks()) != 1 { |
| + t.Fatal("the beacon must actually have been served — otherwise this proves nothing") |
| + } |
| + if rows := um.Drain(); len(rows) != 0 { |
| + t.Fatalf("usage rows = %+v, want nothing billed under %s", rows, ReservedPrefix) |
| + } |
| +} |