diff --git a/README.md b/README.md index 8e8d3be..4550654 100644 --- a/README.md +++ b/README.md @@ -90,41 +90,51 @@ All servers are designed for testing purposes: Both servers provide the following endpoints: -| Endpoint | Method | Description | -| ------------------- | ------ | ------------------------------- | -| `/` | GET | Web UI | -| `/doc` | GET | API documentation (Markdown) | -| `/api/entries` | GET | List all entries (newest first) | -| `/api/entries/{id}` | GET | Get specific entry | -| `/api/entries/{id}` | DELETE | Delete specific entry | -| `/api/entries` | DELETE | Clear all entries | -| `/api/stats` | GET | Store statistics | -| `/api/events` | GET | Server-Sent Events stream | -| `/health` | GET | Health check | +| Endpoint | Method | Description | +| ------------------- | ------ | -------------------------------- | +| `/` | GET | Web UI | +| `/doc` | GET | API documentation (Markdown) | +| `/api/entries` | GET | List all entries (newest first) | +| `/api/entries/{id}` | GET | Get specific entry | +| `/api/entries/{id}` | DELETE | Delete specific entry | +| `/api/entries` | DELETE | Clear all entries | +| `/api/stats` | GET | Store statistics | +| `/api/count` | GET | Count entries matching filter | +| `/api/await` | GET | Wait for entries matching filter | +| `/api/events` | GET | Server-Sent Events stream | +| `/health` | GET | Health check | ### Filtering `GET /api/entries` supports query parameters for filtering: -| Parameter | Description | trap-smtp | trap-webhook | -| ---------------- | ------------------------------- | --------- | ------------ | -| `from` | Contains match on sender | ✓ | | -| `to` | Contains match on any recipient | ✓ | | -| `subject` | Contains match on subject | ✓ | | -| `method` | Exact match on HTTP method | | ✓ | -| `path` | Contains match on request path | | ✓ | -| `query` | Contains match on query string | | ✓ | -| `body` | Contains match on body | ✓ | ✓ | -| `jsonpath` | JSONPath expression for body | ✓ | ✓ | -| `jsonpath_value` | Expected value at JSONPath | ✓ | ✓ | -| `content_type` | Contains match on Content-Type | | ✓ | -| `header` | Header name to check | ✓ | ✓ | -| `header_value` | Header value contains match | ✓ | ✓ | -| `host` | Contains match on Host header | | ✓ | -| `since` | ReceivedAt after (RFC3339) | ✓ | ✓ | -| `until` | ReceivedAt before (RFC3339) | ✓ | ✓ | -| `limit` | Maximum number of results | ✓ | ✓ | -| `offset` | Skip first N results | ✓ | ✓ | +| Parameter | Description | trap-smtp | trap-webhook | +| -------------------- | ------------------------------- | --------- | ------------ | +| `from` | Contains match on sender | ✓ | | +| `from_regex` | Regex match on sender | ✓ | | +| `to` | Contains match on any recipient | ✓ | | +| `to_regex` | Regex match on any recipient | ✓ | | +| `subject` | Contains match on subject | ✓ | | +| `subject_regex` | Regex match on subject | ✓ | | +| `method` | Exact match on HTTP method | | ✓ | +| `path` | Contains match on request path | | ✓ | +| `path_regex` | Regex match on request path | | ✓ | +| `query` | Contains match on query string | | ✓ | +| `query_regex` | Regex match on query string | | ✓ | +| `body` | Contains match on body | ✓ | ✓ | +| `body_regex` | Regex match on body | ✓ | ✓ | +| `jsonpath` | JSONPath expression for body | ✓ | ✓ | +| `jsonpath_value` | Expected value at JSONPath | ✓ | ✓ | +| `content_type` | Contains match on Content-Type | | ✓ | +| `content_type_regex` | Regex match on Content-Type | | ✓ | +| `header` | Header name to check | ✓ | ✓ | +| `header_value` | Header value contains match | ✓ | ✓ | +| `host` | Contains match on Host header | | ✓ | +| `host_regex` | Regex match on Host header | | ✓ | +| `since` | ReceivedAt after (RFC3339) | ✓ | ✓ | +| `until` | ReceivedAt before (RFC3339) | ✓ | ✓ | +| `limit` | Maximum number of results | ✓ | ✓ | +| `offset` | Skip first N results | ✓ | ✓ | ### trap-smtp specific diff --git a/trap-smtp/go.mod b/trap-smtp/go.mod index 0217868..4dce616 100644 --- a/trap-smtp/go.mod +++ b/trap-smtp/go.mod @@ -1,4 +1,4 @@ -module github.com/probitas-test/state-servers/state-smtp +module github.com/probitas-test/state-servers/trap-smtp go 1.25 diff --git a/trap-smtp/handlers/api.go b/trap-smtp/handlers/api.go index e69ebb4..2b0081d 100644 --- a/trap-smtp/handlers/api.go +++ b/trap-smtp/handlers/api.go @@ -2,15 +2,17 @@ package handlers import ( "encoding/json" + "fmt" "mime" "net/http" + "regexp" "strconv" "strings" "time" "github.com/go-chi/chi/v5" - "github.com/probitas-test/state-servers/state-smtp/store" + "github.com/probitas-test/state-servers/trap-smtp/store" ) var emailStore *store.Store @@ -22,7 +24,11 @@ func SetStore(s *store.Store) { // ListEntriesHandler returns stored email entries with optional filtering func ListEntriesHandler(w http.ResponseWriter, r *http.Request) { - filter := parseEmailFilter(r) + filter, err := parseEmailFilter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } entries := emailStore.ListWithFilter(filter) w.Header().Set("Content-Type", "application/json") @@ -32,7 +38,7 @@ func ListEntriesHandler(w http.ResponseWriter, r *http.Request) { } // parseEmailFilter extracts filter parameters from the request -func parseEmailFilter(r *http.Request) *store.EmailFilter { +func parseEmailFilter(r *http.Request) (*store.EmailFilter, error) { q := r.URL.Query() filter := &store.EmailFilter{ @@ -46,6 +52,26 @@ func parseEmailFilter(r *http.Request) *store.EmailFilter { HeaderValue: q.Get("header_value"), } + // Parse regex filters + regexFields := []struct { + param string + dest **regexp.Regexp + }{ + {"from_regex", &filter.FromRegex}, + {"to_regex", &filter.ToRegex}, + {"subject_regex", &filter.SubjectRegex}, + {"body_regex", &filter.BodyRegex}, + } + for _, rf := range regexFields { + if v := q.Get(rf.param); v != "" { + re, err := regexp.Compile(v) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", rf.param, err) + } + *rf.dest = re + } + } + // Parse time filters if since := q.Get("since"); since != "" { if t, err := time.Parse(time.RFC3339, since); err == nil { @@ -70,7 +96,7 @@ func parseEmailFilter(r *http.Request) *store.EmailFilter { } } - return filter + return filter, nil } // GetEntryHandler returns a specific entry by ID diff --git a/trap-smtp/handlers/await.go b/trap-smtp/handlers/await.go new file mode 100644 index 0000000..63d0d6c --- /dev/null +++ b/trap-smtp/handlers/await.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/probitas-test/state-servers/trap-smtp/store" +) + +// AwaitHandler blocks until the specified number of entries match the filter, +// or the timeout is reached. Returns matched entries on success, 408 on timeout. +// +// Query parameters: +// - count: minimum number of matching entries to wait for (default: 1) +// - timeout: maximum wait duration, e.g. "5s", "500ms" (default: 10s) +// - All email filter parameters (from, to, subject, body, etc.) +func AwaitHandler(w http.ResponseWriter, r *http.Request) { + filter, err := parseAwaitFilter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + count := 1 + if c := r.URL.Query().Get("count"); c != "" { + n, err := strconv.Atoi(c) + if err != nil || n <= 0 { + http.Error(w, "invalid 'count' parameter: must be a positive integer", http.StatusBadRequest) + return + } + count = n + } + + timeout := 10 * time.Second + if t := r.URL.Query().Get("timeout"); t != "" { + d, err := time.ParseDuration(t) + if err != nil || d <= 0 { + http.Error(w, "invalid 'timeout' parameter: must be a positive duration (e.g. '5s', '500ms')", http.StatusBadRequest) + return + } + timeout = d + } + + // Subscribe before checking existing entries to avoid missing entries + // added between the check and subscribe. + ch := emailStore.Subscribe() + defer emailStore.Unsubscribe(ch) + + // Check existing entries + entries := emailStore.ListWithFilter(filter) + if len(entries) >= count { + resp, err := json.Marshal(entries) + if err != nil { + http.Error(w, "Failed to encode entries", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + // Unable to write response; nothing more we can do here. + return + } + return + } + + // Wait for new entries + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case _, ok := <-ch: + if !ok { + http.Error(w, "Store closed", http.StatusInternalServerError) + return + } + entries = emailStore.ListWithFilter(filter) + if len(entries) >= count { + resp, err := json.Marshal(entries) + if err != nil { + http.Error(w, "Failed to encode entries", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + // Unable to write response; nothing more we can do here. + return + } + return + } + case <-timer.C: + resp, err := json.Marshal(map[string]interface{}{ + "error": "timeout", + "matched": len(emailStore.ListWithFilter(filter)), + "expected": count, + }) + if err != nil { + http.Error(w, "Failed to encode timeout response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestTimeout) + _, _ = w.Write(resp) + return + case <-r.Context().Done(): + return + } + } +} + +// parseAwaitFilter extracts filter parameters without pagination (limit/offset). +func parseAwaitFilter(r *http.Request) (*store.EmailFilter, error) { + filter, err := parseEmailFilter(r) + if err != nil { + return nil, err + } + // Clear pagination fields (not supported by await) + filter.Limit = 0 + filter.Offset = 0 + return filter, nil +} diff --git a/trap-smtp/handlers/await_test.go b/trap-smtp/handlers/await_test.go new file mode 100644 index 0000000..f72a637 --- /dev/null +++ b/trap-smtp/handlers/await_test.go @@ -0,0 +1,224 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/probitas-test/state-servers/trap-smtp/handlers" + "github.com/probitas-test/state-servers/trap-smtp/store" +) + +func setupAwaitRouter() (*chi.Mux, *store.Store) { + s := store.New(100, 0) + handlers.SetStore(s) + + r := chi.NewRouter() + r.Get("/api/await", handlers.AwaitHandler) + return r, s +} + +func TestAwaitHandler_ExistingEntries(t *testing.T) { + r, s := setupAwaitRouter() + + s.Add(&store.EmailEntry{From: "test@example.com", Subject: "Hello"}) + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=1s&from=test@example.com", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.EmailEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } +} + +func TestAwaitHandler_WaitsForEntry(t *testing.T) { + r, s := setupAwaitRouter() + + go func() { + time.Sleep(50 * time.Millisecond) + s.Add(&store.EmailEntry{From: "test@example.com"}) + }() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&from=test@example.com", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.EmailEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } +} + +func TestAwaitHandler_Timeout(t *testing.T) { + r, _ := setupAwaitRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=200ms&from=nonexistent@example.com", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusRequestTimeout { + t.Errorf("status = %d, want %d", w.Code, http.StatusRequestTimeout) + } +} + +func TestAwaitHandler_WithFilter(t *testing.T) { + r, s := setupAwaitRouter() + + // Add non-matching entry + s.Add(&store.EmailEntry{From: "other@example.com", Subject: "Other"}) + + // Add matching entry after delay + go func() { + time.Sleep(50 * time.Millisecond) + s.Add(&store.EmailEntry{From: "target@example.com", Subject: "Target"}) + }() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&from=target@example.com", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.EmailEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } + if entries[0].From != "target@example.com" { + t.Errorf("From = %q, want %q", entries[0].From, "target@example.com") + } +} + +func TestAwaitHandler_MultipleCount(t *testing.T) { + r, s := setupAwaitRouter() + + s.Add(&store.EmailEntry{From: "test@example.com", Subject: "First"}) + + go func() { + time.Sleep(50 * time.Millisecond) + s.Add(&store.EmailEntry{From: "test@example.com", Subject: "Second"}) + }() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=2&timeout=2s&from=test@example.com", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.EmailEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 2 { + t.Errorf("len(entries) = %d, want 2", len(entries)) + } +} + +func TestAwaitHandler_DefaultCount(t *testing.T) { + r, s := setupAwaitRouter() + + s.Add(&store.EmailEntry{From: "test@example.com"}) + + // No count param - should default to 1 + req := httptest.NewRequest(http.MethodGet, "/api/await?timeout=1s&from=test@example.com", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.EmailEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } +} + +func TestAwaitHandler_InvalidCount(t *testing.T) { + r, _ := setupAwaitRouter() + + tests := []struct { + name string + count string + }{ + {"negative", "-1"}, + {"zero", "0"}, + {"non-integer", "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/api/await?count=" + tt.count + "&timeout=1s" + + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d for count=%s", w.Code, http.StatusBadRequest, tt.count) + } + }) + } +} + +func TestAwaitHandler_InvalidTimeout(t *testing.T) { + r, _ := setupAwaitRouter() + + tests := []struct { + name string + timeout string + }{ + {"negative", "-1s"}, + {"zero", "0s"}, + {"malformed", "invalid"}, + {"no-unit", "5"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/api/await?count=1&timeout=" + tt.timeout + + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d for timeout=%s", w.Code, http.StatusBadRequest, tt.timeout) + } + }) + } +} diff --git a/trap-smtp/handlers/count.go b/trap-smtp/handlers/count.go new file mode 100644 index 0000000..ed839dc --- /dev/null +++ b/trap-smtp/handlers/count.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// CountHandler returns the number of entries matching the filter criteria. +func CountHandler(w http.ResponseWriter, r *http.Request) { + filter, err := parseEmailFilter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Ignore pagination for counting + filter.Limit = 0 + filter.Offset = 0 + + entries := emailStore.ListWithFilter(filter) + + resp, err := json.Marshal(map[string]interface{}{ + "count": len(entries), + }) + if err != nil { + http.Error(w, "failed to marshal count response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + // Unable to write response; nothing more we can do here. + return + } +} diff --git a/trap-smtp/handlers/count_test.go b/trap-smtp/handlers/count_test.go new file mode 100644 index 0000000..28c4920 --- /dev/null +++ b/trap-smtp/handlers/count_test.go @@ -0,0 +1,141 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/probitas-test/state-servers/trap-smtp/handlers" + "github.com/probitas-test/state-servers/trap-smtp/store" +) + +func setupCountRouter() (*chi.Mux, *store.Store) { + s := store.New(100, 0) + handlers.SetStore(s) + + r := chi.NewRouter() + r.Get("/api/count", handlers.CountHandler) + return r, s +} + +func TestCountHandler_Empty(t *testing.T) { + r, _ := setupCountRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/count", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 0 { + t.Errorf("count = %v, want 0", resp["count"]) + } +} + +func TestCountHandler_NoFilter(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.EmailEntry{From: "a@example.com"}) + s.Add(&store.EmailEntry{From: "b@example.com"}) + s.Add(&store.EmailEntry{From: "c@example.com"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 3 { + t.Errorf("count = %v, want 3", resp["count"]) + } +} + +func TestCountHandler_WithFilter(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Hello"}) + s.Add(&store.EmailEntry{From: "bob@example.com", Subject: "Hello"}) + s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Goodbye"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count?from=alice", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 2 { + t.Errorf("count = %v, want 2", resp["count"]) + } +} + +func TestCountHandler_MultipleFilters(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Hello"}) + s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Goodbye"}) + s.Add(&store.EmailEntry{From: "bob@example.com", Subject: "Hello"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count?from=alice&subject=Hello", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 1 { + t.Errorf("count = %v, want 1", resp["count"]) + } +} + +func TestCountHandler_NoMatch(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.EmailEntry{From: "alice@example.com"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count?from=nonexistent", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 0 { + t.Errorf("count = %v, want 0", resp["count"]) + } +} diff --git a/trap-smtp/handlers/doc/api.md b/trap-smtp/handlers/doc/api.md index 455351c..1d2a6be 100644 --- a/trap-smtp/handlers/doc/api.md +++ b/trap-smtp/handlers/doc/api.md @@ -58,13 +58,18 @@ List all stored email entries with optional filtering. | `jsonpath_value` | string | Expected value at JSONPath | | `header` | string | Header name to check | | `header_value` | string | Header value contains match | +| `from_regex` | string | Regex match on sender address | +| `to_regex` | string | Regex match on any recipient | +| `subject_regex` | string | Regex match on subject | +| `body_regex` | string | Regex match on body | | `since` | string | ReceivedAt after (RFC3339 format) | | `until` | string | ReceivedAt before (RFC3339 format) | | `limit` | int | Maximum number of results | | `offset` | int | Skip first N results | > **Note:** All filters use AND logic. Empty filters return all entries -> (backward compatible). +> (backward compatible). Regex filters use Go's `regexp` syntax. Invalid regex +> returns 400 Bad Request. **Request:** @@ -238,6 +243,106 @@ curl "http://localhost:8080/api/stats" } ``` +### GET /api/count + +Get the number of entries matching the filter criteria. Useful for quick +assertions without fetching full entry data. + +**Query Parameters:** + +Same filter parameters as `GET /api/entries` (`from`, `from_regex`, `to`, +`to_regex`, `subject`, `subject_regex`, `body`, `body_regex`, `jsonpath`, +`jsonpath_value`, `header`, `header_value`, `since`, `until`). Pagination +parameters (`limit`, `offset`) are ignored. + +**Request:** + +```bash +# Count all entries +curl "http://localhost:8080/api/count" + +# Count emails from a specific sender +curl "http://localhost:8080/api/count?from=test@example.com" + +# Count with multiple filters +curl "http://localhost:8080/api/count?from=alice&subject=welcome" +``` + +**Response:** + +```json +{ + "count": 3 +} +``` + +### GET /api/await + +Block until the specified number of entries match the filter criteria, or the +timeout is reached. This eliminates the need for polling or `sleep` in tests. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | -------------------------------------------- | +| `count` | int | `1` | Minimum number of matching entries to return | +| `timeout` | string | `10s` | Maximum wait duration (Go duration format) | + +All email filter parameters (`from`, `to`, `subject`, `body`, `from_regex`, +`to_regex`, `subject_regex`, `body_regex`, `jsonpath`, `jsonpath_value`, +`header`, `header_value`, `since`, `until`) are also supported. Pagination +parameters (`limit`, `offset`) are not supported. + +**Request:** + +```bash +# Wait for 1 email from a specific sender (up to 5 seconds) +curl "http://localhost:8080/api/await?from=test@example.com&timeout=5s" + +# Wait for 2 emails matching a subject pattern +curl "http://localhost:8080/api/await?subject=welcome&count=2&timeout=10s" +``` + +**Success Response (200):** + +Returns all matching entries when the count threshold is met. + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "received_at": "2025-01-27T10:30:00Z", + "from": "test@example.com", + "to": ["recipient@example.com"], + "subject": "Welcome", + "body": "Hello!" + } +] +``` + +**Timeout Response (408):** + +```json +{ + "error": "timeout", + "matched": 0, + "expected": 1 +} +``` + +**Test Example (using curl in a CI pipeline):** + +```bash +# 1. Send an email to trap-smtp +swaks --to test@example.com --from sender@example.com \ + --server localhost:2525 --header "Subject: Order #123" + +# 2. Wait for the email to arrive and verify +curl -sf "http://localhost:8080/api/await?subject=Order&timeout=5s" | \ + jq '.[0].subject' +# Output: "Order #123" +``` + ### GET /api/events Server-Sent Events (SSE) stream for real-time email notifications. @@ -399,6 +504,7 @@ with smtplib.SMTP('localhost', 2525) as smtp: | Field | Type | Description | | -------------- | ------------------- | ------------------------------ | | `id` | string | Unique identifier (UUID) | +| `seq` | int64 | Monotonically increasing seq# | | `received_at` | string (RFC3339) | Timestamp when email received | | `from` | string | Sender address (envelope) | | `to` | []string | Recipient addresses (envelope) | @@ -408,4 +514,19 @@ with smtplib.SMTP('localhost', 2525) as smtp: | `content_type` | string | Content-Type header | | `headers` | map[string][]string | All email headers | | `body` | string | Email body content | +| `html_body` | string | HTML version (if available) | +| `text_body` | string | Plain text version | | `raw_email` | string | Complete raw email | +| `attachments` | []Attachment | File attachments | + +### Attachment + +| Field | Type | Description | +| -------------- | ------ | ------------------------------------------- | +| `id` | string | Unique identifier for download | +| `filename` | string | Original filename | +| `content_type` | string | MIME type | +| `size` | int | Size in bytes | +| `sha256` | string | SHA-256 hex digest of the binary data | +| `content_id` | string | Content-ID for inline images (cid:xxx) | +| `is_inline` | bool | True if inline attachment (e.g., cid image) | diff --git a/trap-smtp/handlers/handlers_test.go b/trap-smtp/handlers/handlers_test.go index 7ad948a..72697ce 100644 --- a/trap-smtp/handlers/handlers_test.go +++ b/trap-smtp/handlers/handlers_test.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" - "github.com/probitas-test/state-servers/state-smtp/handlers" - "github.com/probitas-test/state-servers/state-smtp/store" + "github.com/probitas-test/state-servers/trap-smtp/handlers" + "github.com/probitas-test/state-servers/trap-smtp/store" ) func setupTestRouter() (*chi.Mux, *store.Store) { @@ -197,6 +197,43 @@ func TestStatsHandler(t *testing.T) { } } +func TestListEntriesHandler_RegexFilter(t *testing.T) { + r, s := setupTestRouter() + + s.Add(&store.EmailEntry{From: "alice@example.com", Subject: "Hello"}) + s.Add(&store.EmailEntry{From: "bob@test.com", Subject: "Welcome"}) + s.Add(&store.EmailEntry{From: "alice@other.com", Subject: "Re: Hello"}) + + req := httptest.NewRequest(http.MethodGet, "/api/entries?from_regex=^alice@", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.EmailEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 2 { + t.Errorf("len(entries) = %d, want 2", len(entries)) + } +} + +func TestListEntriesHandler_InvalidRegex(t *testing.T) { + r, _ := setupTestRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/entries?subject_regex=[invalid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + func TestUIHandler(t *testing.T) { r, _ := setupTestRouter() diff --git a/trap-smtp/main.go b/trap-smtp/main.go index 0bd30cf..792c10e 100644 --- a/trap-smtp/main.go +++ b/trap-smtp/main.go @@ -9,9 +9,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/probitas-test/state-servers/state-smtp/handlers" - smtpbackend "github.com/probitas-test/state-servers/state-smtp/smtp" - "github.com/probitas-test/state-servers/state-smtp/store" + "github.com/probitas-test/state-servers/trap-smtp/handlers" + smtpbackend "github.com/probitas-test/state-servers/trap-smtp/smtp" + "github.com/probitas-test/state-servers/trap-smtp/store" ) func main() { @@ -74,6 +74,8 @@ func startHTTPServer(cfg *Config) { r.Delete("/entries", handlers.ClearEntriesHandler) r.Get("/stats", handlers.StatsHandler) r.Get("/events", handlers.SSEHandler) + r.Get("/await", handlers.AwaitHandler) + r.Get("/count", handlers.CountHandler) }) // API documentation diff --git a/trap-smtp/smtp/backend.go b/trap-smtp/smtp/backend.go index 658a8ad..0fa15c8 100644 --- a/trap-smtp/smtp/backend.go +++ b/trap-smtp/smtp/backend.go @@ -2,8 +2,10 @@ package smtp import ( "bytes" + "crypto/sha256" "encoding/base64" "errors" + "fmt" "io" "mime" "mime/multipart" @@ -15,7 +17,7 @@ import ( "github.com/emersion/go-smtp" "github.com/google/uuid" - "github.com/probitas-test/state-servers/state-smtp/store" + "github.com/probitas-test/state-servers/trap-smtp/store" ) // AuthConfig holds authentication settings @@ -308,6 +310,7 @@ func parseMultipart(body []byte, contentType string, result *parsedBody) { Filename: filename, ContentType: partContentType, Size: len(decodedData), + Sha256: fmt.Sprintf("%x", sha256.Sum256(decodedData)), ContentID: contentID, IsInline: isInline && contentID != "", Data: decodedData, diff --git a/trap-smtp/smtp/backend_test.go b/trap-smtp/smtp/backend_test.go index 0096f03..bb232fe 100644 --- a/trap-smtp/smtp/backend_test.go +++ b/trap-smtp/smtp/backend_test.go @@ -1,14 +1,17 @@ package smtp_test import ( + "crypto/sha256" + "encoding/base64" + "fmt" "strings" "testing" "github.com/emersion/go-sasl" gosmtp "github.com/emersion/go-smtp" - "github.com/probitas-test/state-servers/state-smtp/smtp" - "github.com/probitas-test/state-servers/state-smtp/store" + "github.com/probitas-test/state-servers/trap-smtp/smtp" + "github.com/probitas-test/state-servers/trap-smtp/store" ) // noAuth returns an AuthConfig with no credentials (open relay mode) @@ -576,3 +579,105 @@ func TestSession_MultipartBase64Encoded(t *testing.T) { t.Errorf("Body should contain decoded Japanese text, got: %q", entry.Body) } } + +func TestSession_AttachmentSha256(t *testing.T) { + s := store.New(100, 0) + backend := smtp.NewBackend(s, noAuth()) + + session, _ := backend.NewSession(nil) + + _ = session.Mail("sender@example.com", &gosmtp.MailOptions{}) + _ = session.Rcpt("recipient@example.com", &gosmtp.RcptOptions{}) + + // Known attachment content and its SHA-256 + attachmentContent := []byte("Hello, World!") + encodedContent := base64.StdEncoding.EncodeToString(attachmentContent) + expectedHash := fmt.Sprintf("%x", sha256.Sum256(attachmentContent)) + + emailContent := "From: sender@example.com\r\n" + + "To: recipient@example.com\r\n" + + "Subject: Attachment Test\r\n" + + "Content-Type: multipart/mixed; boundary=\"boundary-attach\"\r\n" + + "\r\n" + + "--boundary-attach\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + + "Email body\r\n" + + "--boundary-attach\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Disposition: attachment; filename=\"test.bin\"\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + encodedContent + "\r\n" + + "--boundary-attach--\r\n" + + err := session.Data(strings.NewReader(emailContent)) + if err != nil { + t.Fatalf("Data() error = %v", err) + } + + entries := s.List() + entry := entries[0] + + if len(entry.Attachments) != 1 { + t.Fatalf("len(Attachments) = %d, want 1", len(entry.Attachments)) + } + + att := entry.Attachments[0] + if att.Sha256 != expectedHash { + t.Errorf("Sha256 = %q, want %q", att.Sha256, expectedHash) + } + if att.Filename != "test.bin" { + t.Errorf("Filename = %q, want %q", att.Filename, "test.bin") + } +} + +func TestSession_AttachmentSha256_InlineImage(t *testing.T) { + s := store.New(100, 0) + backend := smtp.NewBackend(s, noAuth()) + + session, _ := backend.NewSession(nil) + + _ = session.Mail("sender@example.com", &gosmtp.MailOptions{}) + _ = session.Rcpt("recipient@example.com", &gosmtp.RcptOptions{}) + + // Fake 1x1 PNG pixel data + imageData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + encodedImage := base64.StdEncoding.EncodeToString(imageData) + expectedHash := fmt.Sprintf("%x", sha256.Sum256(imageData)) + + emailContent := "From: sender@example.com\r\n" + + "To: recipient@example.com\r\n" + + "Subject: Inline Image Test\r\n" + + "Content-Type: multipart/related; boundary=\"boundary-inline\"\r\n" + + "\r\n" + + "--boundary-inline\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "\r\n" + + "\r\n" + + "--boundary-inline\r\n" + + "Content-Type: image/png\r\n" + + "Content-Disposition: inline; filename=\"pixel.png\"\r\n" + + "Content-Id: \r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + encodedImage + "\r\n" + + "--boundary-inline--\r\n" + + err := session.Data(strings.NewReader(emailContent)) + if err != nil { + t.Fatalf("Data() error = %v", err) + } + + entries := s.List() + entry := entries[0] + + if len(entry.Attachments) != 1 { + t.Fatalf("len(Attachments) = %d, want 1", len(entry.Attachments)) + } + + att := entry.Attachments[0] + if att.Sha256 != expectedHash { + t.Errorf("Sha256 = %q, want %q", att.Sha256, expectedHash) + } +} diff --git a/trap-smtp/state-smtp b/trap-smtp/state-smtp deleted file mode 100755 index 08071d0..0000000 Binary files a/trap-smtp/state-smtp and /dev/null differ diff --git a/trap-smtp/store/filter.go b/trap-smtp/store/filter.go index 6d0184c..91b2f00 100644 --- a/trap-smtp/store/filter.go +++ b/trap-smtp/store/filter.go @@ -3,6 +3,7 @@ package store import ( "encoding/json" "fmt" + "regexp" "strings" "time" @@ -23,6 +24,12 @@ type EmailFilter struct { Until *time.Time // ReceivedAt before Limit int // Max results (0 = no limit) Offset int // Skip first N results + + // Regex filters (compiled regular expressions) + FromRegex *regexp.Regexp // Regex match on sender + ToRegex *regexp.Regexp // Regex match on any recipient + SubjectRegex *regexp.Regexp // Regex match on subject + BodyRegex *regexp.Regexp // Regex match on body } // IsEmpty returns true if no filter criteria are set @@ -34,7 +41,11 @@ func (f *EmailFilter) IsEmpty() bool { f.JSONPath == "" && f.Header == "" && f.Since == nil && - f.Until == nil + f.Until == nil && + f.FromRegex == nil && + f.ToRegex == nil && + f.SubjectRegex == nil && + f.BodyRegex == nil } // ListWithFilter returns entries matching the filter criteria @@ -61,6 +72,9 @@ func matchesEmailFilter(entry *EmailEntry, filter *EmailFilter) bool { if filter.From != "" && !containsIgnoreCase(entry.From, filter.From) { return false } + if filter.FromRegex != nil && !filter.FromRegex.MatchString(entry.From) { + return false + } // To filter (any recipient must match) if filter.To != "" { @@ -75,16 +89,34 @@ func matchesEmailFilter(entry *EmailEntry, filter *EmailFilter) bool { return false } } + if filter.ToRegex != nil { + found := false + for _, to := range entry.To { + if filter.ToRegex.MatchString(to) { + found = true + break + } + } + if !found { + return false + } + } // Subject filter if filter.Subject != "" && !containsIgnoreCase(entry.Subject, filter.Subject) { return false } + if filter.SubjectRegex != nil && !filter.SubjectRegex.MatchString(entry.Subject) { + return false + } // Body filter if filter.Body != "" && !containsIgnoreCase(entry.Body, filter.Body) { return false } + if filter.BodyRegex != nil && !filter.BodyRegex.MatchString(entry.Body) { + return false + } // JSONPath filter if filter.JSONPath != "" { diff --git a/trap-smtp/store/filter_test.go b/trap-smtp/store/filter_test.go index 4f0cdfe..e744b2d 100644 --- a/trap-smtp/store/filter_test.go +++ b/trap-smtp/store/filter_test.go @@ -1,6 +1,7 @@ package store import ( + "regexp" "testing" "time" ) @@ -187,6 +188,41 @@ func TestStore_ListWithFilter(t *testing.T) { filter: &EmailFilter{From: "nonexistent"}, wantCount: 0, }, + { + name: "filter by from regex", + filter: &EmailFilter{FromRegex: regexp.MustCompile(`^alice@`)}, + wantCount: 1, + wantIDs: []string{"1"}, + }, + { + name: "filter by to regex (any recipient)", + filter: &EmailFilter{ToRegex: regexp.MustCompile(`(admin|support)@`)}, + wantCount: 1, + wantIDs: []string{"2"}, + }, + { + name: "filter by subject regex", + filter: &EmailFilter{SubjectRegex: regexp.MustCompile(`^Re:`)}, + wantCount: 1, + wantIDs: []string{"3"}, + }, + { + name: "filter by body regex", + filter: &EmailFilter{BodyRegex: regexp.MustCompile(`"event":"\w+"`)}, + wantCount: 1, + wantIDs: []string{"2"}, + }, + { + name: "regex combined with contains filter (AND logic)", + filter: &EmailFilter{From: "example.com", SubjectRegex: regexp.MustCompile(`^Hello`)}, + wantCount: 1, + wantIDs: []string{"1"}, + }, + { + name: "regex no match", + filter: &EmailFilter{SubjectRegex: regexp.MustCompile(`^Goodbye`)}, + wantCount: 0, + }, } for _, tt := range tests { diff --git a/trap-smtp/store/store.go b/trap-smtp/store/store.go index 50269b0..6184abd 100644 --- a/trap-smtp/store/store.go +++ b/trap-smtp/store/store.go @@ -13,6 +13,7 @@ type Attachment struct { Filename string `json:"filename"` // Original filename ContentType string `json:"content_type"` // MIME type Size int `json:"size"` // Size in bytes + Sha256 string `json:"sha256"` // SHA-256 hash of the binary data ContentID string `json:"content_id"` // Content-ID for inline images (cid:xxx) IsInline bool `json:"is_inline"` // True if inline attachment Data []byte `json:"-"` // Binary data (not serialized to JSON) @@ -21,6 +22,7 @@ type Attachment struct { // EmailEntry represents a single received email type EmailEntry struct { ID string `json:"id"` + Seq int64 `json:"seq"` ReceivedAt time.Time `json:"received_at"` // SMTP envelope @@ -49,6 +51,7 @@ type Store struct { mu sync.RWMutex entries map[string]*EmailEntry order []string // maintains insertion order for FIFO eviction + nextSeq int64 maxEntries int ttl time.Duration listeners map[chan *EmailEntry]struct{} @@ -92,6 +95,8 @@ func (s *Store) Add(entry *EmailEntry) string { delete(s.entries, oldestID) } + s.nextSeq++ + entry.Seq = s.nextSeq s.entries[entry.ID] = entry s.order = append(s.order, entry.ID) diff --git a/trap-smtp/store/store_test.go b/trap-smtp/store/store_test.go index 0443e78..aaff6d3 100644 --- a/trap-smtp/store/store_test.go +++ b/trap-smtp/store/store_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/probitas-test/state-servers/state-smtp/store" + "github.com/probitas-test/state-servers/trap-smtp/store" ) func TestStore_Add_And_Get(t *testing.T) { @@ -204,6 +204,59 @@ func TestStore_Subscribe_ReceivesNewEntries(t *testing.T) { } } +func TestStore_Seq_MonotonicallyIncreasing(t *testing.T) { + t.Parallel() + + s := store.New(100, 0) + + s.Add(&store.EmailEntry{Subject: "First"}) + s.Add(&store.EmailEntry{Subject: "Second"}) + s.Add(&store.EmailEntry{Subject: "Third"}) + + list := s.List() // newest first + + if list[0].Seq != 3 { + t.Errorf("list[0].Seq = %d, want 3", list[0].Seq) + } + if list[1].Seq != 2 { + t.Errorf("list[1].Seq = %d, want 2", list[1].Seq) + } + if list[2].Seq != 1 { + t.Errorf("list[2].Seq = %d, want 1", list[2].Seq) + } +} + +func TestStore_Seq_StartsAtOne(t *testing.T) { + t.Parallel() + + s := store.New(100, 0) + + s.Add(&store.EmailEntry{Subject: "First"}) + + entry, _ := s.Get(s.List()[0].ID) + if entry.Seq != 1 { + t.Errorf("first entry Seq = %d, want 1", entry.Seq) + } +} + +func TestStore_Seq_SurvivesEviction(t *testing.T) { + t.Parallel() + + s := store.New(2, 0) // max 2 entries + + s.Add(&store.EmailEntry{Subject: "1"}) // seq=1, will be evicted + s.Add(&store.EmailEntry{Subject: "2"}) // seq=2 + s.Add(&store.EmailEntry{Subject: "3"}) // seq=3, evicts "1" + + list := s.List() // newest first + if list[0].Seq != 3 { + t.Errorf("list[0].Seq = %d, want 3", list[0].Seq) + } + if list[1].Seq != 2 { + t.Errorf("list[1].Seq = %d, want 2", list[1].Seq) + } +} + func TestStore_ConcurrentAccess(t *testing.T) { t.Parallel() diff --git a/trap-webhook/go.mod b/trap-webhook/go.mod index 17173f9..a058849 100644 --- a/trap-webhook/go.mod +++ b/trap-webhook/go.mod @@ -1,4 +1,4 @@ -module github.com/probitas-test/state-servers/state-webhook +module github.com/probitas-test/state-servers/trap-webhook go 1.25 diff --git a/trap-webhook/handlers/api.go b/trap-webhook/handlers/api.go index 64ecac0..6f77669 100644 --- a/trap-webhook/handlers/api.go +++ b/trap-webhook/handlers/api.go @@ -2,18 +2,24 @@ package handlers import ( "encoding/json" + "fmt" "net/http" + "regexp" "strconv" "time" "github.com/go-chi/chi/v5" - "github.com/probitas-test/state-servers/state-webhook/store" + "github.com/probitas-test/state-servers/trap-webhook/store" ) // ListEntriesHandler returns stored webhook entries with optional filtering func ListEntriesHandler(w http.ResponseWriter, r *http.Request) { - filter := parseWebhookFilter(r) + filter, err := parseWebhookFilter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } entries := webhookStore.ListWithFilter(filter) w.Header().Set("Content-Type", "application/json") @@ -23,7 +29,7 @@ func ListEntriesHandler(w http.ResponseWriter, r *http.Request) { } // parseWebhookFilter extracts filter parameters from the request -func parseWebhookFilter(r *http.Request) *store.WebhookFilter { +func parseWebhookFilter(r *http.Request) (*store.WebhookFilter, error) { q := r.URL.Query() filter := &store.WebhookFilter{ @@ -39,6 +45,27 @@ func parseWebhookFilter(r *http.Request) *store.WebhookFilter { Host: q.Get("host"), } + // Parse regex filters + regexFields := []struct { + param string + dest **regexp.Regexp + }{ + {"path_regex", &filter.PathRegex}, + {"query_regex", &filter.QueryRegex}, + {"body_regex", &filter.BodyRegex}, + {"content_type_regex", &filter.ContentTypeRegex}, + {"host_regex", &filter.HostRegex}, + } + for _, rf := range regexFields { + if v := q.Get(rf.param); v != "" { + re, err := regexp.Compile(v) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", rf.param, err) + } + *rf.dest = re + } + } + // Parse time filters if since := q.Get("since"); since != "" { if t, err := time.Parse(time.RFC3339, since); err == nil { @@ -63,7 +90,7 @@ func parseWebhookFilter(r *http.Request) *store.WebhookFilter { } } - return filter + return filter, nil } // GetEntryHandler returns a specific entry by ID diff --git a/trap-webhook/handlers/await.go b/trap-webhook/handlers/await.go new file mode 100644 index 0000000..bcda29e --- /dev/null +++ b/trap-webhook/handlers/await.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/probitas-test/state-servers/trap-webhook/store" +) + +// AwaitHandler blocks until the specified number of entries match the filter, +// or the timeout is reached. Returns matched entries on success, 408 on timeout. +// +// Query parameters: +// - count: minimum number of matching entries to wait for (default: 1) +// - timeout: maximum wait duration, e.g. "5s", "500ms" (default: 10s) +// - All webhook filter parameters (method, path, body, etc.) +func AwaitHandler(w http.ResponseWriter, r *http.Request) { + filter, err := parseAwaitFilter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + count := 1 + if c := r.URL.Query().Get("count"); c != "" { + n, err := strconv.Atoi(c) + if err != nil || n <= 0 { + http.Error(w, "invalid 'count' parameter: must be a positive integer", http.StatusBadRequest) + return + } + count = n + } + + timeout := 10 * time.Second + if t := r.URL.Query().Get("timeout"); t != "" { + d, err := time.ParseDuration(t) + if err != nil || d <= 0 { + http.Error(w, "invalid 'timeout' parameter: must be a positive duration (e.g. '5s', '500ms')", http.StatusBadRequest) + return + } + timeout = d + } + + // Subscribe before checking existing entries to avoid missing entries + // added between the check and subscribe. + ch := webhookStore.Subscribe() + defer webhookStore.Unsubscribe(ch) + + // Check existing entries + entries := webhookStore.ListWithFilter(filter) + if len(entries) >= count { + resp, err := json.Marshal(entries) + if err != nil { + http.Error(w, "Failed to encode entries", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + // Unable to write response; nothing more we can do here. + return + } + return + } + + // Wait for new entries + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case _, ok := <-ch: + if !ok { + http.Error(w, "Store closed", http.StatusInternalServerError) + return + } + entries = webhookStore.ListWithFilter(filter) + if len(entries) >= count { + resp, err := json.Marshal(entries) + if err != nil { + http.Error(w, "Failed to encode entries", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + // Unable to write response; nothing more we can do here. + return + } + return + } + case <-timer.C: + resp, err := json.Marshal(map[string]interface{}{ + "error": "timeout", + "matched": len(webhookStore.ListWithFilter(filter)), + "expected": count, + }) + if err != nil { + http.Error(w, "Failed to encode timeout response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestTimeout) + _, _ = w.Write(resp) + return + case <-r.Context().Done(): + return + } + } +} + +// parseAwaitFilter extracts filter parameters without pagination (limit/offset). +func parseAwaitFilter(r *http.Request) (*store.WebhookFilter, error) { + filter, err := parseWebhookFilter(r) + if err != nil { + return nil, err + } + // Clear pagination fields (not supported by await) + filter.Limit = 0 + filter.Offset = 0 + return filter, nil +} diff --git a/trap-webhook/handlers/await_test.go b/trap-webhook/handlers/await_test.go new file mode 100644 index 0000000..ae9a1ea --- /dev/null +++ b/trap-webhook/handlers/await_test.go @@ -0,0 +1,224 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/probitas-test/state-servers/trap-webhook/handlers" + "github.com/probitas-test/state-servers/trap-webhook/store" +) + +func setupAwaitRouter() (*chi.Mux, *store.Store) { + s := store.New(100, 0) + handlers.SetStore(s) + + r := chi.NewRouter() + r.Get("/api/await", handlers.AwaitHandler) + return r, s +} + +func TestAwaitHandler_ExistingEntries(t *testing.T) { + r, s := setupAwaitRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"}) + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=1s&path=test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.WebhookEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } +} + +func TestAwaitHandler_WaitsForEntry(t *testing.T) { + r, s := setupAwaitRouter() + + go func() { + time.Sleep(50 * time.Millisecond) + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"}) + }() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&path=test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.WebhookEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } +} + +func TestAwaitHandler_Timeout(t *testing.T) { + r, _ := setupAwaitRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=200ms&path=nonexistent", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusRequestTimeout { + t.Errorf("status = %d, want %d", w.Code, http.StatusRequestTimeout) + } +} + +func TestAwaitHandler_WithFilter(t *testing.T) { + r, s := setupAwaitRouter() + + // Add non-matching entry + s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/other"}) + + // Add matching entry after delay + go func() { + time.Sleep(50 * time.Millisecond) + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/target"}) + }() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=1&timeout=2s&method=POST&path=target", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.WebhookEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } + if entries[0].Path != "/webhook/target" { + t.Errorf("Path = %q, want %q", entries[0].Path, "/webhook/target") + } +} + +func TestAwaitHandler_MultipleCount(t *testing.T) { + r, s := setupAwaitRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"}) + + go func() { + time.Sleep(50 * time.Millisecond) + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"}) + }() + + req := httptest.NewRequest(http.MethodGet, "/api/await?count=2&timeout=2s&path=test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.WebhookEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 2 { + t.Errorf("len(entries) = %d, want 2", len(entries)) + } +} + +func TestAwaitHandler_DefaultCount(t *testing.T) { + r, s := setupAwaitRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"}) + + // No count param - should default to 1 + req := httptest.NewRequest(http.MethodGet, "/api/await?timeout=1s&path=test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.WebhookEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 1 { + t.Errorf("len(entries) = %d, want 1", len(entries)) + } +} + +func TestAwaitHandler_InvalidCount(t *testing.T) { + r, _ := setupAwaitRouter() + + tests := []struct { + name string + count string + }{ + {"negative", "-1"}, + {"zero", "0"}, + {"non-integer", "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/api/await?count=" + tt.count + "&timeout=1s" + + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d for count=%s", w.Code, http.StatusBadRequest, tt.count) + } + }) + } +} + +func TestAwaitHandler_InvalidTimeout(t *testing.T) { + r, _ := setupAwaitRouter() + + tests := []struct { + name string + timeout string + }{ + {"negative", "-1s"}, + {"zero", "0s"}, + {"malformed", "invalid"}, + {"no-unit", "5"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/api/await?count=1&timeout=" + tt.timeout + + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d for timeout=%s", w.Code, http.StatusBadRequest, tt.timeout) + } + }) + } +} diff --git a/trap-webhook/handlers/count.go b/trap-webhook/handlers/count.go new file mode 100644 index 0000000..01dc53c --- /dev/null +++ b/trap-webhook/handlers/count.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// CountHandler returns the number of entries matching the filter criteria. +func CountHandler(w http.ResponseWriter, r *http.Request) { + filter, err := parseWebhookFilter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Ignore pagination for counting + filter.Limit = 0 + filter.Offset = 0 + + entries := webhookStore.ListWithFilter(filter) + + resp, err := json.Marshal(map[string]interface{}{ + "count": len(entries), + }) + if err != nil { + http.Error(w, "failed to marshal count response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + // Unable to write response; nothing more we can do here. + return + } +} diff --git a/trap-webhook/handlers/count_test.go b/trap-webhook/handlers/count_test.go new file mode 100644 index 0000000..1bdbbb7 --- /dev/null +++ b/trap-webhook/handlers/count_test.go @@ -0,0 +1,141 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/probitas-test/state-servers/trap-webhook/handlers" + "github.com/probitas-test/state-servers/trap-webhook/store" +) + +func setupCountRouter() (*chi.Mux, *store.Store) { + s := store.New(100, 0) + handlers.SetStore(s) + + r := chi.NewRouter() + r.Get("/api/count", handlers.CountHandler) + return r, s +} + +func TestCountHandler_Empty(t *testing.T) { + r, _ := setupCountRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/count", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 0 { + t.Errorf("count = %v, want 0", resp["count"]) + } +} + +func TestCountHandler_NoFilter(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/a"}) + s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/b"}) + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/c"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 3 { + t.Errorf("count = %v, want 3", resp["count"]) + } +} + +func TestCountHandler_WithFilter(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/a"}) + s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/b"}) + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/c"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count?method=POST", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 2 { + t.Errorf("count = %v, want 2", resp["count"]) + } +} + +func TestCountHandler_MultipleFilters(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/payment"}) + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/notification"}) + s.Add(&store.WebhookEntry{Method: "GET", Path: "/webhook/payment"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count?method=POST&path=payment", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 1 { + t.Errorf("count = %v, want 1", resp["count"]) + } +} + +func TestCountHandler_NoMatch(t *testing.T) { + r, s := setupCountRouter() + + s.Add(&store.WebhookEntry{Method: "POST", Path: "/webhook/test"}) + + req := httptest.NewRequest(http.MethodGet, "/api/count?method=DELETE", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["count"].(float64) != 0 { + t.Errorf("count = %v, want 0", resp["count"]) + } +} diff --git a/trap-webhook/handlers/doc/api.md b/trap-webhook/handlers/doc/api.md index 4a3041d..0cfe6b6 100644 --- a/trap-webhook/handlers/doc/api.md +++ b/trap-webhook/handlers/doc/api.md @@ -67,25 +67,31 @@ List all stored webhook entries with optional filtering. **Query Parameters:** -| Parameter | Type | Description | -| ---------------- | ------ | --------------------------------------------- | -| `method` | string | Exact match on HTTP method (case-insensitive) | -| `path` | string | Contains match on request path | -| `query` | string | Contains match on query string | -| `body` | string | Contains match on request body | -| `jsonpath` | string | JSONPath expression for JSON body | -| `jsonpath_value` | string | Expected value at JSONPath | -| `content_type` | string | Contains match on Content-Type | -| `header` | string | Header name to check | -| `header_value` | string | Header value contains match | -| `host` | string | Contains match on Host header | -| `since` | string | ReceivedAt after (RFC3339 format) | -| `until` | string | ReceivedAt before (RFC3339 format) | -| `limit` | int | Maximum number of results | -| `offset` | int | Skip first N results | +| Parameter | Type | Description | +| -------------------- | ------ | --------------------------------------------- | +| `method` | string | Exact match on HTTP method (case-insensitive) | +| `path` | string | Contains match on request path | +| `query` | string | Contains match on query string | +| `body` | string | Contains match on request body | +| `jsonpath` | string | JSONPath expression for JSON body | +| `jsonpath_value` | string | Expected value at JSONPath | +| `content_type` | string | Contains match on Content-Type | +| `header` | string | Header name to check | +| `header_value` | string | Header value contains match | +| `host` | string | Contains match on Host header | +| `path_regex` | string | Regex match on request path | +| `query_regex` | string | Regex match on query string | +| `body_regex` | string | Regex match on request body | +| `content_type_regex` | string | Regex match on Content-Type | +| `host_regex` | string | Regex match on Host header | +| `since` | string | ReceivedAt after (RFC3339 format) | +| `until` | string | ReceivedAt before (RFC3339 format) | +| `limit` | int | Maximum number of results | +| `offset` | int | Skip first N results | > **Note:** All filters use AND logic. Empty filters return all entries -> (backward compatible). +> (backward compatible). Regex filters use Go's `regexp` syntax. Invalid regex +> returns 400 Bad Request. **Request:** @@ -243,6 +249,105 @@ curl "http://localhost:8080/api/stats" } ``` +### GET /api/count + +Get the number of entries matching the filter criteria. Useful for quick +assertions without fetching full entry data. + +**Query Parameters:** + +Same filter parameters as `GET /api/entries` (`method`, `path`, `query`, `body`, +`jsonpath`, `jsonpath_value`, `content_type`, `header`, `header_value`, `host`, +`path_regex`, `query_regex`, `body_regex`, `content_type_regex`, `host_regex`, +`since`, `until`). Pagination parameters (`limit`, `offset`) are ignored. + +**Request:** + +```bash +# Count all entries +curl "http://localhost:8080/api/count" + +# Count POST requests +curl "http://localhost:8080/api/count?method=POST" + +# Count with multiple filters +curl "http://localhost:8080/api/count?method=POST&path=payment" +``` + +**Response:** + +```json +{ + "count": 3 +} +``` + +### GET /api/await + +Block until the specified number of entries match the filter criteria, or the +timeout is reached. This eliminates the need for polling or `sleep` in tests. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | -------------------------------------------- | +| `count` | int | `1` | Minimum number of matching entries to return | +| `timeout` | string | `10s` | Maximum wait duration (Go duration format) | + +All webhook filter parameters (`method`, `path`, `query`, `body`, `jsonpath`, +`jsonpath_value`, `content_type`, `header`, `header_value`, `host`, `since`, +`until`, `path_regex`, `query_regex`, `body_regex`, `content_type_regex`, +`host_regex`) are also supported. Pagination parameters (`limit`, `offset`) are +not supported. + +**Request:** + +```bash +# Wait for 1 POST webhook (up to 5 seconds) +curl "http://localhost:8080/api/await?method=POST&timeout=5s" + +# Wait for 2 webhooks to a specific path +curl "http://localhost:8080/api/await?path=/webhook/payment&count=2&timeout=10s" +``` + +**Success Response (200):** + +Returns all matching entries when the count threshold is met. + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "received_at": "2025-01-27T10:30:00Z", + "method": "POST", + "path": "/webhook/payment", + "body": "{\"event\": \"payment.completed\"}" + } +] +``` + +**Timeout Response (408):** + +```json +{ + "error": "timeout", + "matched": 0, + "expected": 1 +} +``` + +**Test Example (using curl in a CI pipeline):** + +```bash +# 1. Trigger your app (which sends a webhook to trap-webhook) +curl -X POST http://my-app/process-order + +# 2. Wait for the webhook to arrive and verify +curl -sf "http://localhost:8080/api/await?path=/webhook/payment&timeout=5s" | \ + jq '.[0].body | fromjson | .event' +# Output: "payment.completed" +``` + ### GET /api/events Server-Sent Events (SSE) stream for real-time webhook notifications. @@ -305,6 +410,7 @@ The web UI provides: | Field | Type | Description | | -------------- | ------------------- | ------------------------------- | | `id` | string | Unique identifier (UUID) | +| `seq` | int64 | Monotonically increasing seq# | | `received_at` | string (RFC3339) | Timestamp when request received | | `method` | string | HTTP method (GET, POST, etc.) | | `path` | string | Request path | diff --git a/trap-webhook/handlers/handlers_test.go b/trap-webhook/handlers/handlers_test.go index 0ca054f..70e8f15 100644 --- a/trap-webhook/handlers/handlers_test.go +++ b/trap-webhook/handlers/handlers_test.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" - "github.com/probitas-test/state-servers/state-webhook/handlers" - "github.com/probitas-test/state-servers/state-webhook/store" + "github.com/probitas-test/state-servers/trap-webhook/handlers" + "github.com/probitas-test/state-servers/trap-webhook/store" ) func setupTestRouter() (*chi.Mux, *store.Store) { @@ -225,6 +225,43 @@ func TestStatsHandler(t *testing.T) { } } +func TestListEntriesHandler_RegexFilter(t *testing.T) { + r, s := setupTestRouter() + + s.Add(&store.WebhookEntry{Path: "/api/users"}) + s.Add(&store.WebhookEntry{Path: "/api/webhooks/events"}) + s.Add(&store.WebhookEntry{Path: "/api/users/123"}) + + req := httptest.NewRequest(http.MethodGet, "/api/entries?path_regex=^/api/users", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + + var entries []*store.WebhookEntry + if err := json.NewDecoder(w.Body).Decode(&entries); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(entries) != 2 { + t.Errorf("len(entries) = %d, want 2", len(entries)) + } +} + +func TestListEntriesHandler_InvalidRegex(t *testing.T) { + r, _ := setupTestRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/entries?path_regex=[invalid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + func TestUIHandler(t *testing.T) { r := chi.NewRouter() r.Get("/", handlers.UIHandler) diff --git a/trap-webhook/handlers/webhook.go b/trap-webhook/handlers/webhook.go index 0c76df4..7e1d499 100644 --- a/trap-webhook/handlers/webhook.go +++ b/trap-webhook/handlers/webhook.go @@ -4,7 +4,7 @@ import ( "io" "net/http" - "github.com/probitas-test/state-servers/state-webhook/store" + "github.com/probitas-test/state-servers/trap-webhook/store" ) var webhookStore *store.Store diff --git a/trap-webhook/main.go b/trap-webhook/main.go index 19b7311..4d58d6c 100644 --- a/trap-webhook/main.go +++ b/trap-webhook/main.go @@ -7,8 +7,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/probitas-test/state-servers/state-webhook/handlers" - "github.com/probitas-test/state-servers/state-webhook/store" + "github.com/probitas-test/state-servers/trap-webhook/handlers" + "github.com/probitas-test/state-servers/trap-webhook/store" ) func main() { @@ -33,6 +33,8 @@ func main() { r.Delete("/entries", handlers.ClearEntriesHandler) r.Get("/stats", handlers.StatsHandler) r.Get("/events", handlers.SSEHandler) + r.Get("/await", handlers.AwaitHandler) + r.Get("/count", handlers.CountHandler) }) // Webhook receiver - accepts any method on any path under /webhook/ diff --git a/trap-webhook/store/filter.go b/trap-webhook/store/filter.go index 70dc263..8ff23fb 100644 --- a/trap-webhook/store/filter.go +++ b/trap-webhook/store/filter.go @@ -3,6 +3,7 @@ package store import ( "encoding/json" "fmt" + "regexp" "strings" "time" @@ -25,6 +26,13 @@ type WebhookFilter struct { Until *time.Time // ReceivedAt before Limit int // Max results (0 = no limit) Offset int // Skip first N results + + // Regex filters (compiled regular expressions) + PathRegex *regexp.Regexp // Regex match on path + QueryRegex *regexp.Regexp // Regex match on query string + BodyRegex *regexp.Regexp // Regex match on body + ContentTypeRegex *regexp.Regexp // Regex match on content type + HostRegex *regexp.Regexp // Regex match on host } // IsEmpty returns true if no filter criteria are set @@ -38,7 +46,12 @@ func (f *WebhookFilter) IsEmpty() bool { f.Header == "" && f.Host == "" && f.Since == nil && - f.Until == nil + f.Until == nil && + f.PathRegex == nil && + f.QueryRegex == nil && + f.BodyRegex == nil && + f.ContentTypeRegex == nil && + f.HostRegex == nil } // ListWithFilter returns entries matching the filter criteria @@ -70,16 +83,25 @@ func matchesWebhookFilter(entry *WebhookEntry, filter *WebhookFilter) bool { if filter.Path != "" && !containsIgnoreCase(entry.Path, filter.Path) { return false } + if filter.PathRegex != nil && !filter.PathRegex.MatchString(entry.Path) { + return false + } // Query string filter if filter.Query != "" && !containsIgnoreCase(entry.QueryString, filter.Query) { return false } + if filter.QueryRegex != nil && !filter.QueryRegex.MatchString(entry.QueryString) { + return false + } // Body filter if filter.Body != "" && !containsIgnoreCase(entry.Body, filter.Body) { return false } + if filter.BodyRegex != nil && !filter.BodyRegex.MatchString(entry.Body) { + return false + } // JSONPath filter if filter.JSONPath != "" { @@ -92,6 +114,9 @@ func matchesWebhookFilter(entry *WebhookEntry, filter *WebhookFilter) bool { if filter.ContentType != "" && !containsIgnoreCase(entry.ContentType, filter.ContentType) { return false } + if filter.ContentTypeRegex != nil && !filter.ContentTypeRegex.MatchString(entry.ContentType) { + return false + } // Header filter if filter.Header != "" { @@ -104,6 +129,9 @@ func matchesWebhookFilter(entry *WebhookEntry, filter *WebhookFilter) bool { if filter.Host != "" && !containsIgnoreCase(entry.Host, filter.Host) { return false } + if filter.HostRegex != nil && !filter.HostRegex.MatchString(entry.Host) { + return false + } // Time range filter if !matchTimeRange(entry.ReceivedAt, filter.Since, filter.Until) { diff --git a/trap-webhook/store/filter_test.go b/trap-webhook/store/filter_test.go index db39be9..1372c8b 100644 --- a/trap-webhook/store/filter_test.go +++ b/trap-webhook/store/filter_test.go @@ -1,6 +1,7 @@ package store import ( + "regexp" "testing" "time" ) @@ -205,6 +206,47 @@ func TestStore_ListWithFilter(t *testing.T) { filter: &WebhookFilter{Method: "DELETE"}, wantCount: 0, }, + { + name: "filter by path regex", + filter: &WebhookFilter{PathRegex: regexp.MustCompile(`^/api/users$`)}, + wantCount: 2, + wantIDs: []string{"3", "1"}, + }, + { + name: "filter by path regex (webhooks only)", + filter: &WebhookFilter{PathRegex: regexp.MustCompile(`/webhooks/`)}, + wantCount: 1, + wantIDs: []string{"2"}, + }, + { + name: "filter by body regex", + filter: &WebhookFilter{BodyRegex: regexp.MustCompile(`"event":"user\.\w+"`)}, + wantCount: 1, + wantIDs: []string{"2"}, + }, + { + name: "filter by host regex", + filter: &WebhookFilter{HostRegex: regexp.MustCompile(`^(api|hooks)\.example\.com$`)}, + wantCount: 3, + wantIDs: []string{"3", "2", "1"}, + }, + { + name: "filter by host regex (specific)", + filter: &WebhookFilter{HostRegex: regexp.MustCompile(`^hooks\.`)}, + wantCount: 1, + wantIDs: []string{"2"}, + }, + { + name: "regex combined with contains filter (AND logic)", + filter: &WebhookFilter{Method: "POST", PathRegex: regexp.MustCompile(`/users$`)}, + wantCount: 1, + wantIDs: []string{"3"}, + }, + { + name: "regex no match", + filter: &WebhookFilter{PathRegex: regexp.MustCompile(`^/nonexistent`)}, + wantCount: 0, + }, } for _, tt := range tests { diff --git a/trap-webhook/store/store.go b/trap-webhook/store/store.go index 2df261c..c6d56f7 100644 --- a/trap-webhook/store/store.go +++ b/trap-webhook/store/store.go @@ -10,6 +10,7 @@ import ( // WebhookEntry represents a single received webhook request type WebhookEntry struct { ID string `json:"id"` + Seq int64 `json:"seq"` ReceivedAt time.Time `json:"received_at"` Method string `json:"method"` Path string `json:"path"` @@ -26,6 +27,7 @@ type Store struct { mu sync.RWMutex entries map[string]*WebhookEntry order []string // maintains insertion order for FIFO eviction + nextSeq int64 maxEntries int ttl time.Duration listeners map[chan *WebhookEntry]struct{} @@ -69,6 +71,8 @@ func (s *Store) Add(entry *WebhookEntry) string { delete(s.entries, oldestID) } + s.nextSeq++ + entry.Seq = s.nextSeq s.entries[entry.ID] = entry s.order = append(s.order, entry.ID) diff --git a/trap-webhook/store/store_test.go b/trap-webhook/store/store_test.go index 09617eb..482b813 100644 --- a/trap-webhook/store/store_test.go +++ b/trap-webhook/store/store_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/probitas-test/state-servers/state-webhook/store" + "github.com/probitas-test/state-servers/trap-webhook/store" ) func TestStore_Add_And_Get(t *testing.T) { @@ -204,6 +204,59 @@ func TestStore_Subscribe_ReceivesNewEntries(t *testing.T) { } } +func TestStore_Seq_MonotonicallyIncreasing(t *testing.T) { + t.Parallel() + + s := store.New(100, 0) + + s.Add(&store.WebhookEntry{Path: "/first"}) + s.Add(&store.WebhookEntry{Path: "/second"}) + s.Add(&store.WebhookEntry{Path: "/third"}) + + list := s.List() // newest first + + if list[0].Seq != 3 { + t.Errorf("list[0].Seq = %d, want 3", list[0].Seq) + } + if list[1].Seq != 2 { + t.Errorf("list[1].Seq = %d, want 2", list[1].Seq) + } + if list[2].Seq != 1 { + t.Errorf("list[2].Seq = %d, want 1", list[2].Seq) + } +} + +func TestStore_Seq_StartsAtOne(t *testing.T) { + t.Parallel() + + s := store.New(100, 0) + + s.Add(&store.WebhookEntry{Path: "/first"}) + + entry, _ := s.Get(s.List()[0].ID) + if entry.Seq != 1 { + t.Errorf("first entry Seq = %d, want 1", entry.Seq) + } +} + +func TestStore_Seq_SurvivesEviction(t *testing.T) { + t.Parallel() + + s := store.New(2, 0) // max 2 entries + + s.Add(&store.WebhookEntry{Path: "/1"}) // seq=1, will be evicted + s.Add(&store.WebhookEntry{Path: "/2"}) // seq=2 + s.Add(&store.WebhookEntry{Path: "/3"}) // seq=3, evicts "/1" + + list := s.List() // newest first + if list[0].Seq != 3 { + t.Errorf("list[0].Seq = %d, want 3", list[0].Seq) + } + if list[1].Seq != 2 { + t.Errorf("list[1].Seq = %d, want 2", list[1].Seq) + } +} + func TestStore_ConcurrentAccess(t *testing.T) { t.Parallel()