| new file mode 100644 |
| index 000000000..03199e718 |
| --- /dev/null |
| +++ b/internal/reqrec/recorder_test.go |
| @@ -0,0 +1,241 @@ |
| +package reqrec |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type memSink struct { |
| + recs []map[string]string |
| + msgs, levels []string |
| +} |
| + |
| +func (m *memSink) Edge(acct, app, host, level, msg string, f map[string]string) { |
| + m.recs, m.msgs, m.levels = append(m.recs, f), append(m.msgs, msg), append(m.levels, level) |
| +} |
| + |
| +func TestRecorderNotFoundSampledAndCounted(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 21, 7, 0, 0, time.UTC) |
| + s := &memSink{} |
| + r := NewRecorder(s, func(string) []byte { return bytes.Repeat([]byte{7}, 32) }, func() time.Time { return now }) |
| + for i := 0; i < 10000; i++ { |
| + r.Response(Response{Acct: "a1", App: "shop", Host: "shop.example.com", Method: "GET", |
| + Path: "/wp-login.php", UA: "curl/8.7.1", ClientIP: "203.0.113.7", Status: 404, Woke: i == 0, WokeKnown: true, When: now}) |
| + } |
| + r.Response(Response{Acct: "a1", App: "shop", Method: "GET", Path: "/", Status: 200, WokeKnown: true, When: now}) |
| + r.Flush(now.Add(time.Minute)) |
| + var notfound, minute int |
| + for _, f := range s.recs { |
| + switch f["kind"] { |
| + case "notfound": |
| + notfound++ |
| + if f["client"] != "203.0.113.0/24" || f["ua"] != "tool" || f["status"] != "404" || len(f["cid"]) != 12 { |
| + t.Fatalf("bad notfound fields %v", f) |
| + } |
| + case "minute": |
| + minute++ |
| + if f["s404"] != "10000" || f["s2xx"] != "1" || f["wakes"] != "1" || f["wakes404"] != "1" || f["dropped_notfound"] != "9940" { |
| + t.Fatalf("bad minute %v", f) |
| + } |
| + } |
| + } |
| + if notfound != NotFoundPerMin || minute != 1 { |
| + t.Fatalf("notfound=%d minute=%d", notfound, minute) |
| + } |
| +} |
| + |
| +func TestRecorderMatchRecordsAndCap(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 21, 7, 0, 0, time.UTC) |
| + s := &memSink{} |
| + r := NewRecorder(s, nil, func() time.Time { return now }) |
| + m := Match{Response: Response{Acct: "a1", App: "shop", Method: "GET", Path: "/.env", Status: 404, When: now}, |
| + Kind: KindBlock, Tier: TierHard, RuleID: "dotenv", Ref: "ref1"} |
| + for i := 0; i < MatchPerMin+5; i++ { |
| + r.Match(m) |
| + } |
| + r.Flush(now.Add(time.Minute)) |
| + first := s.recs[0] |
| + if first["kind"] != "block" || first["tier"] != "hard" || first["rule"] != "dotenv" || first["ref"] != "ref1" || s.levels[0] != "warn" { |
| + t.Fatalf("bad match record %v level %s", first, s.levels[0]) |
| + } |
| + if _, ok := first["woke"]; ok { |
| + t.Error("woke written while unknown") |
| + } |
| + if first["cid"] != "" { |
| + t.Error("cid written without a salt") |
| + } |
| + last := s.recs[len(s.recs)-1] |
| + if last["kind"] != "minute" || last["block"] != fmt.Sprint(MatchPerMin+5) || last["dropped_match"] != "5" { |
| + t.Fatalf("bad minute %v", last) |
| + } |
| +} |
| + |
| +func TestRecorderFlushOnlyCompletedMinutes(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 21, 7, 30, 0, time.UTC) |
| + s := &memSink{} |
| + r := NewRecorder(s, nil, func() time.Time { return now }) |
| + r.Response(Response{Acct: "a1", App: "shop", Status: 200, When: now}) |
| + r.Flush(now) // minute 21:07 not complete |
| + if len(s.recs) != 0 { |
| + t.Fatal("flushed an open minute") |
| + } |
| + r.Flush(now.Add(31 * time.Second)) |
| + if len(s.recs) != 1 || s.recs[0]["minute"] != "2026-10-24T21:07:00Z" { |
| + t.Fatalf("got %v", s.recs) |
| + } |
| +} |
| + |
| +func TestRecorderStatusClassesAndMatchCounts(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 21, 7, 0, 0, time.UTC) |
| + s := &memSink{} |
| + r := NewRecorder(s, nil, func() time.Time { return now }) |
| + for _, st := range []int{200, 204, 301, 400, 403, 404, 500, 503} { |
| + r.Response(Response{Acct: "a1", App: "shop", Method: "GET", Path: "/x", Status: st, When: now}) |
| + } |
| + // A watch match counts as a watch and in its status class, and makes no |
| + // not-found record of its own even for a 404. |
| + r.Match(Match{Response: Response{Acct: "a1", App: "shop", Method: "GET", Path: "/y", Status: 404, Woke: true, WokeKnown: true, When: now}, |
| + Kind: KindWatch, Tier: TierProbe, RuleID: "r"}) |
| + r.Flush(now.Add(time.Minute)) |
| + var min map[string]string |
| + var kinds []string |
| + for _, f := range s.recs { |
| + kinds = append(kinds, f["kind"]) |
| + if f["kind"] == "minute" { |
| + min = f |
| + } |
| + } |
| + want := map[string]string{"s2xx": "2", "s3xx": "1", "s4xx": "2", "s404": "2", "s5xx": "2", |
| + "wakes": "1", "wakes404": "1", "block": "0", "watch": "1", "dropped_notfound": "0", "dropped_match": "0"} |
| + for k, v := range want { |
| + if min[k] != v { |
| + t.Errorf("%s = %q, want %q (%v)", k, min[k], v, min) |
| + } |
| + } |
| + if min["minute"] != "2026-10-24T21:07:00Z" || s.msgs[len(s.msgs)-1] != "minute 2026-10-24T21:07:00Z" { |
| + t.Errorf("bad minute label %v %q", min, s.msgs[len(s.msgs)-1]) |
| + } |
| + nf, w := 0, 0 |
| + for _, k := range kinds { |
| + if k == "notfound" { |
| + nf++ |
| + } |
| + if k == "watch" { |
| + w++ |
| + } |
| + } |
| + // The plain 404 gets a record, the watch match gets one, the 404 under a |
| + // match does not get a second. |
| + if nf != 1 || w != 1 { |
| + t.Errorf("kinds %v", kinds) |
| + } |
| +} |
| + |
| +func TestRecorderNotFoundMessageAndOmittedFields(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 21, 7, 0, 0, time.UTC) |
| + s := &memSink{} |
| + r := NewRecorder(s, nil, func() time.Time { return now }) |
| + r.Response(Response{Acct: "a1", App: "shop", Method: "GET", Path: "/a", Status: 404, When: now}) |
| + f := s.recs[0] |
| + if s.msgs[0] != "404 GET /a" || s.levels[0] != "info" { |
| + t.Errorf("msg %q level %q", s.msgs[0], s.levels[0]) |
| + } |
| + for _, k := range []string{"country", "cid", "qnames", "woke", "client"} { |
| + if v, ok := f[k]; ok { |
| + t.Errorf("%s present as %q, want omitted", k, v) |
| + } |
| + } |
| + m := Match{Response: Response{Acct: "a1", App: "shop", Method: "POST", Path: "/b", Status: 403, Woke: false, WokeKnown: true, When: now}, |
| + Kind: KindWatch, Tier: TierProbe, RuleID: "r1", Fragment: "x"} |
| + r.Match(m) |
| + if s.msgs[1] != "watch probe r1 POST /b" || s.levels[1] != "info" || s.recs[1]["woke"] != "0" || s.recs[1]["frag"] != "x" { |
| + t.Errorf("match record %q %q %v", s.msgs[1], s.levels[1], s.recs[1]) |
| + } |
| +} |
| + |
| +func TestRecorderSaltOncePerDayAndFlushOrder(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 23, 59, 0, 0, time.UTC) |
| + s := &memSink{} |
| + var days []string |
| + r := NewRecorder(s, func(day string) []byte { |
| + days = append(days, day) |
| + return []byte("salt-" + day) |
| + }, func() time.Time { return now }) |
| + for i := 0; i < 5; i++ { |
| + r.Response(Response{Acct: "a1", App: "shop", Method: "GET", Path: "/", ClientIP: "198.51.100.9", Status: 404, When: now}) |
| + } |
| + next := now.Add(2 * time.Minute) // 00:01 the next day |
| + r.Response(Response{Acct: "a1", App: "shop", Method: "GET", Path: "/", ClientIP: "198.51.100.9", Status: 404, When: next}) |
| + r.Response(Response{Acct: "a1", App: "blog", Method: "GET", Path: "/", Status: 200, When: now}) |
| + if len(days) != 2 || days[0] != "2026-10-24" || days[1] != "2026-10-25" { |
| + t.Fatalf("salt days %v", days) |
| + } |
| + if s.recs[0]["cid"] == s.recs[5]["cid"] { |
| + t.Error("cid did not change across days") |
| + } |
| + s.recs, s.msgs = nil, nil |
| + r.Flush(next.Add(time.Minute)) |
| + if len(s.msgs) != 3 || s.msgs[0] != "minute 2026-10-24T23:59:00Z" || s.msgs[2] != "minute 2026-10-25T00:01:00Z" { |
| + t.Fatalf("flush order %v", s.msgs) |
| + } |
| + r.Flush(next.Add(time.Hour)) |
| + if len(s.msgs) != 3 { |
| + t.Fatal("flushed minutes were not forgotten") |
| + } |
| +} |
| + |
| +func TestRecorderNil(t *testing.T) { |
| + var r *Recorder |
| + r.Response(Response{}) |
| + r.Match(Match{}) |
| + r.Flush(time.Now()) |
| +} |
| + |
| +func TestRecorderConcurrentResponses(t *testing.T) { |
| + now := time.Date(2026, 10, 24, 21, 7, 0, 0, time.UTC) |
| + s := &lockedSink{} |
| + r := NewRecorder(s, func(string) []byte { return []byte("salt") }, func() time.Time { return now }) |
| + var wg sync.WaitGroup |
| + for g := 0; g < 16; g++ { |
| + wg.Add(1) |
| + go func(g int) { |
| + defer wg.Done() |
| + for i := 0; i < 200; i++ { |
| + r.Response(Response{Acct: "a1", App: fmt.Sprint("app", g%3), Method: "GET", Path: "/p", |
| + ClientIP: "203.0.113.7", Status: 404, When: now}) |
| + if i%50 == 0 { |
| + r.Flush(now.Add(time.Minute)) |
| + } |
| + } |
| + }(g) |
| + } |
| + wg.Wait() |
| + r.Flush(now.Add(time.Minute)) |
| + var total int |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + for _, f := range s.recs { |
| + if f["kind"] == "minute" { |
| + var n int |
| + fmt.Sscan(f["s404"], &n) |
| + total += n |
| + } |
| + } |
| + if total != 16*200 { |
| + t.Fatalf("counted %d 404s, want %d", total, 16*200) |
| + } |
| +} |
| + |
| +type lockedSink struct { |
| + mu sync.Mutex |
| + recs []map[string]string |
| +} |
| + |
| +func (l *lockedSink) Edge(acct, app, host, level, msg string, f map[string]string) { |
| + l.mu.Lock() |
| + l.recs = append(l.recs, f) |
| + l.mu.Unlock() |
| +} |