host: request records end-to-end through the real sink and recorder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 file changed,
+145
−0
internal/host/reqrec_e2e_test.go+145 −0
diff --git a/internal/host/reqrec_e2e_test.go b/internal/host/reqrec_e2e_test.go| new file mode 100644 |
| index 000000000..1c5e04b59 |
| --- /dev/null |
| +++ b/internal/host/reqrec_e2e_test.go |
| @@ -0,0 +1,145 @@ |
| +package host |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TestRequestRecordsEndToEnd drives the production wiring end to end: a real |
| +// registry and edge, the recorder newRequestRecorder builds against a real |
| +// applog.Sink, and reqrecLoop closing the minute. Nothing here is a fixture |
| +// of the recorder's inputs, so a seam that drops a field, a stream or the |
| +// salt shows up as a failed assertion. |
| +// |
| +// The traffic is the shape the feature exists for: one password-reset link |
| +// with its secret in the path, then a scanner hammering a missing page. |
| +func TestRequestRecordsEndToEnd(t *testing.T) { |
| + const token = "3f9a0c1d2e4b5a6978877665544332211" |
| + |
| + backend := httptest.NewServer(http.NotFoundHandler()) |
| + t.Cleanup(backend.Close) |
| + |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "shop.example.com", Kind: "service", Account: "bab", App: "shop", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", Version: "v1", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("edge.New: %v", err) |
| + } |
| + |
| + sink := applog.NewSink("box1", time.Now) |
| + rec := newRequestRecorder(sink, t.TempDir(), func(string) string { return "" }) |
| + if rec == nil { |
| + t.Fatal("newRequestRecorder returned nil with a sink and no kill switch") |
| + } |
| + e.EnableRequestRecords(rec) |
| + |
| + get := func(path string) { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + r := httptest.NewRequest("GET", "http://shop.example.com"+path, nil) |
| + r.RemoteAddr = "203.0.113.7:51234" |
| + e.ServeHTTP(w, r) |
| + if w.Code != http.StatusNotFound { |
| + t.Fatalf("GET %s = %d, want 404 from the backend", path, w.Code) |
| + } |
| + } |
| + // The reset link goes first so it is inside the 60-record sample, which |
| + // is the case where the token would leak if masking were not applied. |
| + get("/reset/" + token) |
| + for i := 0; i < 70; i++ { |
| + get("/wp-login.php") |
| + } |
| + |
| + tick := make(chan time.Time, 1) |
| + tick <- time.Now().Add(time.Minute) |
| + close(tick) |
| + reqrecLoop(rec, tick) |
| + |
| + recs := sink.Drain()[applog.AppKey{Acct: "bab", App: "shop"}] |
| + var notFound, minutes []applog.Record |
| + for _, r := range recs { |
| + if r.Stream != applog.StreamEdge { |
| + t.Fatalf("record on stream %q, want the edge stream: %+v", r.Stream, r) |
| + } |
| + switch r.Fields["kind"] { |
| + case "notfound": |
| + notFound = append(notFound, r) |
| + case "minute": |
| + minutes = append(minutes, r) |
| + } |
| + } |
| + if len(notFound) != 60 { |
| + t.Errorf("%d notfound records, want 60 (the per-minute cap)", len(notFound)) |
| + } |
| + if len(minutes) != 1 { |
| + t.Fatalf("%d minute records, want 1 (records: %d)", len(minutes), len(recs)) |
| + } |
| + if got := minutes[0].Fields["s404"]; got != "71" { |
| + t.Errorf("minute s404 = %q, want 71", got) |
| + } |
| + if got := minutes[0].Fields["dropped_notfound"]; got != "11" { |
| + t.Errorf("minute dropped_notfound = %q, want 11", got) |
| + } |
| + |
| + // The secret must not appear in any field, key or message. |
| + leaks := func(recs []applog.Record, where string) { |
| + t.Helper() |
| + for _, r := range recs { |
| + if strings.Contains(r.Msg, token) { |
| + t.Errorf("%s: raw token in message %q", where, r.Msg) |
| + } |
| + for k, v := range r.Fields { |
| + if strings.Contains(k, token) || strings.Contains(v, token) { |
| + t.Errorf("%s: raw token in field %s=%q", where, k, v) |
| + } |
| + } |
| + } |
| + } |
| + // Masking must have happened rather than the record being dropped: the |
| + // reset link is in the sample, with its secret segment replaced. |
| + masked := false |
| + for _, r := range notFound { |
| + if r.Fields["path"] == "/reset/[masked]" { |
| + masked = true |
| + } |
| + } |
| + if !masked { |
| + t.Error("no notfound record carries the masked reset path") |
| + } |
| + leaks(recs, "drained") |
| + |
| + // What ships is the encoded chunk, so the records must survive that trip |
| + // unchanged and the token must be absent from the bytes themselves. |
| + b, err := applog.EncodeChunk(recs) |
| + if err != nil { |
| + t.Fatalf("EncodeChunk: %v", err) |
| + } |
| + if strings.Contains(string(b), token) { |
| + t.Error("raw token in the encoded chunk") |
| + } |
| + back, err := applog.DecodeChunk(b) |
| + if err != nil { |
| + t.Fatalf("DecodeChunk: %v", err) |
| + } |
| + if !reflect.DeepEqual(back, recs) { |
| + t.Errorf("records changed across EncodeChunk/DecodeChunk:\n got %+v\nwant %+v", back, recs) |
| + } |
| + leaks(back, "decoded") |
| +} |