From 8945d5c93d6072cf96e38940c667b86933f1d032 Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 17 Mar 2026 01:38:17 +0000 Subject: [PATCH 1/3] fix: parse JSON filter flags as objects and status codes as integers --- pkg/cmd/connection_common.go | 38 +++++++++++++---- pkg/cmd/connection_upsert_test.go | 71 +++++++++++++++++++++++++++---- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/connection_common.go b/pkg/cmd/connection_common.go index 009bb05f..1fbe390c 100644 --- a/pkg/cmd/connection_common.go +++ b/pkg/cmd/connection_common.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -158,16 +159,16 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { if f.RuleFilterBody != "" || f.RuleFilterHeaders != "" || f.RuleFilterQuery != "" || f.RuleFilterPath != "" { rule := hookdeck.Rule{"type": "filter"} if f.RuleFilterBody != "" { - rule["body"] = f.RuleFilterBody + rule["body"] = parseJSONOrString(f.RuleFilterBody) } if f.RuleFilterHeaders != "" { - rule["headers"] = f.RuleFilterHeaders + rule["headers"] = parseJSONOrString(f.RuleFilterHeaders) } if f.RuleFilterQuery != "" { - rule["query"] = f.RuleFilterQuery + rule["query"] = parseJSONOrString(f.RuleFilterQuery) } if f.RuleFilterPath != "" { - rule["path"] = f.RuleFilterPath + rule["path"] = parseJSONOrString(f.RuleFilterPath) } rules = append(rules, rule) } @@ -191,14 +192,35 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { rule["interval"] = f.RuleRetryInterval } if f.RuleRetryResponseStatusCode != "" { - codes := strings.Split(f.RuleRetryResponseStatusCode, ",") - for i := range codes { - codes[i] = strings.TrimSpace(codes[i]) + parts := strings.Split(f.RuleRetryResponseStatusCode, ",") + intCodes := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid HTTP status code %q in --rule-retry-response-status-codes: must be an integer", part) + } + intCodes = append(intCodes, n) } - rule["response_status_codes"] = codes + rule["response_status_codes"] = intCodes } rules = append(rules, rule) } return rules, nil } + +// parseJSONOrString attempts to parse s as JSON. If successful it returns the +// parsed value (object, array, number, bool, etc.); otherwise it returns s as +// a plain string. This lets filter flags accept both JSON objects and JQ +// expressions transparently. +func parseJSONOrString(s string) interface{} { + var v interface{} + if err := json.Unmarshal([]byte(s), &v); err == nil { + return v + } + return s +} diff --git a/pkg/cmd/connection_upsert_test.go b/pkg/cmd/connection_upsert_test.go index 5a6490f7..23869304 100644 --- a/pkg/cmd/connection_upsert_test.go +++ b/pkg/cmd/connection_upsert_test.go @@ -13,14 +13,69 @@ func strPtr(s string) *string { return &s } +// TestBuildConnectionRulesFilterHeadersJSON verifies that --rule-filter-headers +// parses JSON values into objects rather than storing them as escaped strings. +// Regression test for https://github.com/hookdeck/hookdeck-cli/issues/192. +func TestBuildConnectionRulesFilterHeadersJSON(t *testing.T) { + t.Run("JSON object should be parsed, not stored as string", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterHeaders: `{"x-shopify-topic":{"$startsWith":"order/"}}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + filterRule := rules[0] + assert.Equal(t, "filter", filterRule["type"]) + + headers := filterRule["headers"] + _, isString := headers.(string) + assert.False(t, isString, "headers should be a parsed object, not a string") + + headersMap, isMap := headers.(map[string]interface{}) + assert.True(t, isMap, "headers should be map[string]interface{}, got %T", headers) + assert.Contains(t, headersMap, "x-shopify-topic") + }) + + t.Run("non-JSON string (JQ expression) should remain a string", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterHeaders: `.["x-topic"] == "order"`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + filterRule := rules[0] + headers := filterRule["headers"] + _, isString := headers.(string) + assert.True(t, isString, "non-JSON value should remain a string") + }) + + t.Run("filter body JSON should also be parsed", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterBody: `{"event_type":"payment"}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + filterRule := rules[0] + body := filterRule["body"] + _, isString := body.(string) + assert.False(t, isString, "body should be a parsed object, not a string") + _, isMap := body.(map[string]interface{}) + assert.True(t, isMap, "body should be map[string]interface{}, got %T", body) + }) +} + // TestBuildConnectionRulesRetryStatusCodesArray verifies that buildConnectionRules -// produces response_status_codes as a []string array, not a single string. +// produces response_status_codes as a []int array (HTTP status codes are integers). // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 3. func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { tests := []struct { name string flags connectionRuleFlags - wantCodes []string + wantCodes []int wantCodeCount int wantRuleCount int }{ @@ -32,7 +87,7 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { RuleRetryInterval: 5000, RuleRetryResponseStatusCode: "500,502,503,504", }, - wantCodes: []string{"500", "502", "503", "504"}, + wantCodes: []int{500, 502, 503, 504}, wantCodeCount: 4, wantRuleCount: 1, }, @@ -42,7 +97,7 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { RuleRetryStrategy: "exponential", RuleRetryResponseStatusCode: "500", }, - wantCodes: []string{"500"}, + wantCodes: []int{500}, wantCodeCount: 1, wantRuleCount: 1, }, @@ -52,7 +107,7 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { RuleRetryStrategy: "linear", RuleRetryResponseStatusCode: "500, 502, 503", }, - wantCodes: []string{"500", "502", "503"}, + wantCodes: []int{500, 502, 503}, wantCodeCount: 3, wantRuleCount: 1, }, @@ -90,12 +145,12 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { statusCodes, ok := retryRule["response_status_codes"] require.True(t, ok, "response_status_codes should be present") - codesSlice, ok := statusCodes.([]string) - require.True(t, ok, "response_status_codes should be []string, got %T", statusCodes) + codesSlice, ok := statusCodes.([]int) + require.True(t, ok, "response_status_codes should be []int, got %T", statusCodes) assert.Equal(t, tt.wantCodeCount, len(codesSlice)) assert.Equal(t, tt.wantCodes, codesSlice) - // Verify it serializes to a JSON array + // Verify it serializes to a JSON array of numbers jsonBytes, err := json.Marshal(retryRule) require.NoError(t, err) From c3a7e2ea3c1fdbf361810b50b2d7e107b32b775d Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 17 Mar 2026 19:23:09 +0000 Subject: [PATCH 2/3] test(acceptance): add end-to-end tests for filter flag JSON parsing (issue #192) --- test/acceptance/connection_upsert_test.go | 131 ++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/test/acceptance/connection_upsert_test.go b/test/acceptance/connection_upsert_test.go index fc6702fd..56bafefc 100644 --- a/test/acceptance/connection_upsert_test.go +++ b/test/acceptance/connection_upsert_test.go @@ -441,4 +441,135 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { t.Logf("Successfully upserted connection %s with source-name only", connID) }) + + // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/192: + // --rule-filter-headers (and other filter flags) should store JSON as a parsed + // object, not as an escaped string. + t.Run("FilterHeadersJSONStoredAsObject", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-filter-headers-" + timestamp + sourceName := "test-filter-src-" + timestamp + destName := "test-filter-dst-" + timestamp + + // Create a connection using --rule-filter-headers with a JSON object + var createResp map[string]interface{} + err := cli.RunJSON(&createResp, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhook", + "--rule-filter-headers", `{"x-shopify-topic":{"$startsWith":"order/"}}`, + ) + require.NoError(t, err, "Should create connection with --rule-filter-headers JSON") + + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in response") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + // Verify source and destination were created correctly + source, ok := createResp["source"].(map[string]interface{}) + require.True(t, ok, "Expected source object in response") + assert.Equal(t, sourceName, source["name"], "Source name should match") + + dest, ok := createResp["destination"].(map[string]interface{}) + require.True(t, ok, "Expected destination object in response") + assert.Equal(t, destName, dest["name"], "Destination name should match") + + // Verify the filter rule has headers as a JSON object, not an escaped string + rules, ok := createResp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array in response") + + foundFilter := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "filter" { + continue + } + foundFilter = true + + headers := rule["headers"] + _, isString := headers.(string) + assert.False(t, isString, + "--rule-filter-headers should store JSON as an object, not an escaped string; got: %v", headers) + + headersMap, isMap := headers.(map[string]interface{}) + assert.True(t, isMap, + "headers should be a JSON object (map[string]interface{}), got %T", headers) + assert.Contains(t, headersMap, "x-shopify-topic", + "headers object should contain the expected key") + break + } + assert.True(t, foundFilter, "Should have a filter rule") + + t.Logf("Successfully verified --rule-filter-headers stores JSON as object for connection %s", connID) + }) + + // Verify that --rule-filter-body JSON is also stored as an object. + t.Run("FilterBodyJSONStoredAsObject", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-filter-body-" + timestamp + sourceName := "test-filter-body-src-" + timestamp + destName := "test-filter-body-dst-" + timestamp + + var createResp map[string]interface{} + err := cli.RunJSON(&createResp, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhook", + "--rule-filter-body", `{"event_type":"payment"}`, + ) + require.NoError(t, err, "Should create connection with --rule-filter-body JSON") + + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in response") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + rules, ok := createResp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array in response") + + foundFilter := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "filter" { + continue + } + foundFilter = true + + body := rule["body"] + _, isString := body.(string) + assert.False(t, isString, + "--rule-filter-body should store JSON as an object, not an escaped string; got: %v", body) + + bodyMap, isMap := body.(map[string]interface{}) + assert.True(t, isMap, "body should be a JSON object, got %T", body) + assert.Contains(t, bodyMap, "event_type", "body object should contain the expected key") + break + } + assert.True(t, foundFilter, "Should have a filter rule") + + t.Logf("Successfully verified --rule-filter-body stores JSON as object for connection %s", connID) + }) } From 6c747f5be9ef87934259749313c46f9a890f57d1 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 17 Mar 2026 23:05:46 +0000 Subject: [PATCH 3/3] Claude/review fix pr 262 ubt6o (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: address PR #262 review feedback - parseJSONOrString: only parse JSON objects/arrays (starting with { or [), not bare primitives like "order", 123, true that could be JQ expressions - Add HTTP status code range validation (100-599) for --rule-retry-response-status-codes - Fix UpsertRetryResponseStatusCodesAsArray acceptance test: status codes are now integers (float64 after JSON round-trip), not strings - Upgrade assert.True to require.True for map type assertions to prevent nil-pointer panics on failure - Update filter flag help text to mention JSON object support alongside JQ - Remove unused strings import from acceptance test https://claude.ai/code/session_01No6Vv4niFtYoF56at7uL2P * fix: remove incorrect JQ expression references from flag descriptions and tests The CLI does not support JQ expressions — filter flags accept JSON objects using Hookdeck filter syntax. Updated flag help text to match the listen command's wording and removed misleading JQ references from test names and comments. https://claude.ai/code/session_01No6Vv4niFtYoF56at7uL2P * test: verify exact JSON values in filter, retry, and transform tests Strengthen unit and acceptance tests to check full structure down to exact values instead of only checking types and key presence. Add coverage for --rule-filter-query, --rule-filter-path, combined filter flags, --rule-transform-env, and retry status code exact ordering. https://claude.ai/code/session_01No6Vv4niFtYoF56at7uL2P * test: add exact-value tests for source/destination --config and connection --rules Add unit and acceptance tests verifying that JSON input via --config, --config-file, --rules, and --rules-file on sources, destinations, and connections is parsed correctly and the API returns resources with the exact same structure and values. Covers inline JSON strings and file input for all three resource types. https://claude.ai/code/session_01No6Vv4niFtYoF56at7uL2P * fix: acceptance tests - response_status_codes as strings, filter rule assertions - Send response_status_codes as []string per API (RetryRule schema) - normalizeRulesForAPI: convert numeric codes to strings for --rules/--rules-file - assertFilterRuleFieldMatches: accept body/headers as string or map from API - assertResponseStatusCodesMatch: accept codes as string or number in responses - Update comments to avoid referencing local-only OpenAPI file Made-with: Cursor * test: expect response_status_codes as []string in unit tests (match API schema) Made-with: Cursor --------- Co-authored-by: Claude --- pkg/cmd/connection_common.go | 68 +++- pkg/cmd/connection_rules_json_test.go | 183 +++++++++ pkg/cmd/connection_upsert_test.go | 236 ++++++++++-- pkg/cmd/destination_config_json_test.go | 110 ++++++ pkg/cmd/source_config_json_test.go | 127 ++++++ test/acceptance/connection_rules_json_test.go | 278 ++++++++++++++ test/acceptance/connection_test.go | 16 +- test/acceptance/connection_update_test.go | 8 +- test/acceptance/connection_upsert_test.go | 361 +++++++++++++----- .../destination_config_json_test.go | 148 +++++++ test/acceptance/helpers.go | 42 ++ test/acceptance/source_config_json_test.go | 138 +++++++ 12 files changed, 1564 insertions(+), 151 deletions(-) create mode 100644 pkg/cmd/connection_rules_json_test.go create mode 100644 pkg/cmd/destination_config_json_test.go create mode 100644 pkg/cmd/source_config_json_test.go create mode 100644 test/acceptance/connection_rules_json_test.go create mode 100644 test/acceptance/destination_config_json_test.go create mode 100644 test/acceptance/source_config_json_test.go diff --git a/pkg/cmd/connection_common.go b/pkg/cmd/connection_common.go index 1fbe390c..0029d16b 100644 --- a/pkg/cmd/connection_common.go +++ b/pkg/cmd/connection_common.go @@ -77,10 +77,10 @@ func addConnectionRuleFlags(cmd *cobra.Command, f *connectionRuleFlags) { cmd.Flags().IntVar(&f.RuleRetryInterval, "rule-retry-interval", 0, "Interval between retries in milliseconds") cmd.Flags().StringVar(&f.RuleRetryResponseStatusCode, "rule-retry-response-status-codes", "", "Comma-separated HTTP status codes to retry on") - cmd.Flags().StringVar(&f.RuleFilterBody, "rule-filter-body", "", "JQ expression to filter on request body") - cmd.Flags().StringVar(&f.RuleFilterHeaders, "rule-filter-headers", "", "JQ expression to filter on request headers") - cmd.Flags().StringVar(&f.RuleFilterQuery, "rule-filter-query", "", "JQ expression to filter on request query parameters") - cmd.Flags().StringVar(&f.RuleFilterPath, "rule-filter-path", "", "JQ expression to filter on request path") + cmd.Flags().StringVar(&f.RuleFilterBody, "rule-filter-body", "", "Filter on request body using Hookdeck filter syntax (JSON)") + cmd.Flags().StringVar(&f.RuleFilterHeaders, "rule-filter-headers", "", "Filter on request headers using Hookdeck filter syntax (JSON)") + cmd.Flags().StringVar(&f.RuleFilterQuery, "rule-filter-query", "", "Filter on request query parameters using Hookdeck filter syntax (JSON)") + cmd.Flags().StringVar(&f.RuleFilterPath, "rule-filter-path", "", "Filter on request path using Hookdeck filter syntax (JSON)") cmd.Flags().StringVar(&f.RuleTransformName, "rule-transform-name", "", "Name or ID of the transformation to apply") cmd.Flags().StringVar(&f.RuleTransformCode, "rule-transform-code", "", "Transformation code (if creating inline)") @@ -103,7 +103,7 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { if err := json.Unmarshal([]byte(f.Rules), &rules); err != nil { return nil, fmt.Errorf("invalid JSON for --rules: %w", err) } - return rules, nil + return normalizeRulesForAPI(rules), nil } if f.RulesFile != "" { @@ -115,7 +115,7 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { if err := json.Unmarshal(data, &rules); err != nil { return nil, fmt.Errorf("invalid JSON in rules file: %w", err) } - return rules, nil + return normalizeRulesForAPI(rules), nil } // Build each rule type (order matches create: deduplicate -> transform -> filter -> delay -> retry) @@ -191,9 +191,10 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { if f.RuleRetryInterval > 0 { rule["interval"] = f.RuleRetryInterval } + // API expects response_status_codes as []string (RetryRule schema) if f.RuleRetryResponseStatusCode != "" { parts := strings.Split(f.RuleRetryResponseStatusCode, ",") - intCodes := make([]int, 0, len(parts)) + strCodes := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { @@ -203,9 +204,12 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { if err != nil { return nil, fmt.Errorf("invalid HTTP status code %q in --rule-retry-response-status-codes: must be an integer", part) } - intCodes = append(intCodes, n) + if n < 100 || n > 599 { + return nil, fmt.Errorf("invalid HTTP status code %d in --rule-retry-response-status-codes: must be between 100 and 599", n) + } + strCodes = append(strCodes, part) } - rule["response_status_codes"] = intCodes + rule["response_status_codes"] = strCodes } rules = append(rules, rule) } @@ -213,11 +217,49 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { return rules, nil } -// parseJSONOrString attempts to parse s as JSON. If successful it returns the -// parsed value (object, array, number, bool, etc.); otherwise it returns s as -// a plain string. This lets filter flags accept both JSON objects and JQ -// expressions transparently. +// normalizeRulesForAPI ensures rules match the API schema: RetryRule.response_status_codes +// must be []string; FilterRule body/headers may be string or object. +func normalizeRulesForAPI(rules []hookdeck.Rule) []hookdeck.Rule { + out := make([]hookdeck.Rule, len(rules)) + for i, r := range rules { + out[i] = make(hookdeck.Rule) + for k, v := range r { + out[i][k] = v + } + if r["type"] == "retry" { + if codes, ok := r["response_status_codes"].([]interface{}); ok && len(codes) > 0 { + strCodes := make([]string, 0, len(codes)) + for _, c := range codes { + switch v := c.(type) { + case string: + strCodes = append(strCodes, v) + case float64: + strCodes = append(strCodes, strconv.Itoa(int(v))) + case int: + strCodes = append(strCodes, strconv.Itoa(v)) + default: + strCodes = append(strCodes, fmt.Sprintf("%v", c)) + } + } + out[i]["response_status_codes"] = strCodes + } + } + } + return out +} + +// parseJSONOrString attempts to parse s as a JSON object or array. Only values +// starting with '{' or '[' (after trimming whitespace) are candidates for +// parsing; bare primitives like "order", 123, or true are returned as-is so +// that plain strings are never misinterpreted. func parseJSONOrString(s string) interface{} { + trimmed := strings.TrimSpace(s) + if len(trimmed) == 0 { + return s + } + if trimmed[0] != '{' && trimmed[0] != '[' { + return s + } var v interface{} if err := json.Unmarshal([]byte(s), &v); err == nil { return v diff --git a/pkg/cmd/connection_rules_json_test.go b/pkg/cmd/connection_rules_json_test.go new file mode 100644 index 00000000..77bf1f1c --- /dev/null +++ b/pkg/cmd/connection_rules_json_test.go @@ -0,0 +1,183 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBuildConnectionRulesFromJSONString verifies that --rules (JSON string) parses +// into a rules slice with exact values and structure preserved. +func TestBuildConnectionRulesFromJSONString(t *testing.T) { + t.Run("filter rule JSON with exact nested values", func(t *testing.T) { + input := `[{"type":"filter","headers":{"x-shopify-topic":{"$startsWith":"order/"}},"body":{"event_type":"payment"}}]` + flags := connectionRuleFlags{Rules: input} + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, "filter", rule["type"]) + + headersMap, ok := rule["headers"].(map[string]interface{}) + require.True(t, ok, "headers should be a map, got %T", rule["headers"]) + + topicMap, ok := headersMap["x-shopify-topic"].(map[string]interface{}) + require.True(t, ok, "x-shopify-topic should be a nested map") + assert.Equal(t, "order/", topicMap["$startsWith"]) + + bodyMap, ok := rule["body"].(map[string]interface{}) + require.True(t, ok, "body should be a map") + assert.Equal(t, "payment", bodyMap["event_type"]) + }) + + t.Run("retry rule JSON with exact numeric values", func(t *testing.T) { + input := `[{"type":"retry","strategy":"exponential","count":5,"interval":30000,"response_status_codes":[500,502,503]}]` + flags := connectionRuleFlags{Rules: input} + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, "retry", rule["type"]) + assert.Equal(t, "exponential", rule["strategy"]) + assert.Equal(t, float64(5), rule["count"]) + assert.Equal(t, float64(30000), rule["interval"]) + + statusCodes, ok := rule["response_status_codes"].([]string) + require.True(t, ok, "response_status_codes should be []string (API schema), got %T", rule["response_status_codes"]) + assert.Equal(t, []string{"500", "502", "503"}, statusCodes) + }) + + t.Run("multiple rules JSON preserves all rules with exact values", func(t *testing.T) { + input := `[{"type":"filter","headers":{"content-type":"application/json"}},{"type":"delay","delay":5000},{"type":"retry","strategy":"linear","count":3,"interval":10000}]` + flags := connectionRuleFlags{Rules: input} + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 3) + + // Filter rule + assert.Equal(t, "filter", rules[0]["type"]) + headersMap, ok := rules[0]["headers"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "application/json", headersMap["content-type"]) + + // Delay rule + assert.Equal(t, "delay", rules[1]["type"]) + assert.Equal(t, float64(5000), rules[1]["delay"]) + + // Retry rule + assert.Equal(t, "retry", rules[2]["type"]) + assert.Equal(t, "linear", rules[2]["strategy"]) + assert.Equal(t, float64(3), rules[2]["count"]) + assert.Equal(t, float64(10000), rules[2]["interval"]) + }) + + t.Run("transform rule JSON preserves transformation config", func(t *testing.T) { + input := `[{"type":"transform","transformation":{"name":"my-transform","env":{"API_KEY":"sk-123","MODE":"production"}}}]` + flags := connectionRuleFlags{Rules: input} + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, "transform", rule["type"]) + + transformation, ok := rule["transformation"].(map[string]interface{}) + require.True(t, ok, "transformation should be a map") + assert.Equal(t, "my-transform", transformation["name"]) + + env, ok := transformation["env"].(map[string]interface{}) + require.True(t, ok, "env should be a map") + assert.Equal(t, "sk-123", env["API_KEY"]) + assert.Equal(t, "production", env["MODE"]) + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + flags := connectionRuleFlags{Rules: `[{broken`} + _, err := buildConnectionRules(&flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "--rules") + }) +} + +// TestBuildConnectionRulesFromJSONFile verifies that --rules-file reads a JSON file +// and produces rules with exact values and structure preserved. +func TestBuildConnectionRulesFromJSONFile(t *testing.T) { + t.Run("file with filter and retry rules preserves exact values", func(t *testing.T) { + content := `[{"type":"filter","headers":{"x-event-type":{"$eq":"order.created"}},"body":{"amount":{"$gte":100}}},{"type":"retry","strategy":"linear","count":3,"interval":5000,"response_status_codes":[500,502]}]` + tmpFile := filepath.Join(t.TempDir(), "rules.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + + flags := connectionRuleFlags{RulesFile: tmpFile} + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 2) + + // Filter rule with exact nested values + filterRule := rules[0] + assert.Equal(t, "filter", filterRule["type"]) + + headersMap, ok := filterRule["headers"].(map[string]interface{}) + require.True(t, ok) + eventTypeMap, ok := headersMap["x-event-type"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "order.created", eventTypeMap["$eq"]) + + bodyMap, ok := filterRule["body"].(map[string]interface{}) + require.True(t, ok) + amountMap, ok := bodyMap["amount"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(100), amountMap["$gte"]) + + // Retry rule with exact values + retryRule := rules[1] + assert.Equal(t, "retry", retryRule["type"]) + assert.Equal(t, "linear", retryRule["strategy"]) + assert.Equal(t, float64(3), retryRule["count"]) + assert.Equal(t, float64(5000), retryRule["interval"]) + + statusCodes, ok := retryRule["response_status_codes"].([]string) + require.True(t, ok, "response_status_codes should be []string (API schema)") + assert.Equal(t, []string{"500", "502"}, statusCodes) + }) + + t.Run("file with deduplicate rule preserves fields", func(t *testing.T) { + content := `[{"type":"deduplicate","window":3600,"include_fields":["id","timestamp"]}]` + tmpFile := filepath.Join(t.TempDir(), "dedup-rules.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + + flags := connectionRuleFlags{RulesFile: tmpFile} + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, "deduplicate", rule["type"]) + assert.Equal(t, float64(3600), rule["window"]) + + fields, ok := rule["include_fields"].([]interface{}) + require.True(t, ok, "include_fields should be an array") + assert.Equal(t, []interface{}{"id", "timestamp"}, fields) + }) + + t.Run("nonexistent file returns error", func(t *testing.T) { + flags := connectionRuleFlags{RulesFile: "/nonexistent/rules.json"} + _, err := buildConnectionRules(&flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "rules file") + }) + + t.Run("invalid JSON file returns error", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad-rules.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(`[{invalid`), 0644)) + + flags := connectionRuleFlags{RulesFile: tmpFile} + _, err := buildConnectionRules(&flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "rules file") + }) +} diff --git a/pkg/cmd/connection_upsert_test.go b/pkg/cmd/connection_upsert_test.go index 23869304..bf28291b 100644 --- a/pkg/cmd/connection_upsert_test.go +++ b/pkg/cmd/connection_upsert_test.go @@ -13,11 +13,12 @@ func strPtr(s string) *string { return &s } -// TestBuildConnectionRulesFilterHeadersJSON verifies that --rule-filter-headers -// parses JSON values into objects rather than storing them as escaped strings. +// TestBuildConnectionRulesFilterJSON verifies that all --rule-filter-* flags +// parse JSON values into objects with exact values preserved, not stored as +// escaped strings. // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/192. -func TestBuildConnectionRulesFilterHeadersJSON(t *testing.T) { - t.Run("JSON object should be parsed, not stored as string", func(t *testing.T) { +func TestBuildConnectionRulesFilterJSON(t *testing.T) { + t.Run("headers JSON parsed with exact nested values", func(t *testing.T) { flags := connectionRuleFlags{ RuleFilterHeaders: `{"x-shopify-topic":{"$startsWith":"order/"}}`, } @@ -28,16 +29,143 @@ func TestBuildConnectionRulesFilterHeadersJSON(t *testing.T) { filterRule := rules[0] assert.Equal(t, "filter", filterRule["type"]) - headers := filterRule["headers"] - _, isString := headers.(string) - assert.False(t, isString, "headers should be a parsed object, not a string") + headersMap, ok := filterRule["headers"].(map[string]interface{}) + require.True(t, ok, "headers should be map[string]interface{}, got %T", filterRule["headers"]) + + nestedMap, ok := headersMap["x-shopify-topic"].(map[string]interface{}) + require.True(t, ok, "x-shopify-topic should be a nested object, got %T", headersMap["x-shopify-topic"]) + assert.Equal(t, "order/", nestedMap["$startsWith"], "nested $startsWith value should match exactly") + }) + + t.Run("body JSON parsed with exact values", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterBody: `{"event_type":"payment","amount":{"$gte":100}}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + bodyMap, ok := rules[0]["body"].(map[string]interface{}) + require.True(t, ok, "body should be map[string]interface{}, got %T", rules[0]["body"]) + assert.Equal(t, "payment", bodyMap["event_type"], "event_type value should match exactly") + + amountMap, ok := bodyMap["amount"].(map[string]interface{}) + require.True(t, ok, "amount should be a nested object, got %T", bodyMap["amount"]) + assert.Equal(t, float64(100), amountMap["$gte"], "$gte value should match exactly") + }) + + t.Run("query JSON parsed with exact values", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterQuery: `{"status":"active","page":{"$gte":1}}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + queryMap, ok := rules[0]["query"].(map[string]interface{}) + require.True(t, ok, "query should be map[string]interface{}, got %T", rules[0]["query"]) + assert.Equal(t, "active", queryMap["status"], "status value should match exactly") + + pageMap, ok := queryMap["page"].(map[string]interface{}) + require.True(t, ok, "page should be a nested object, got %T", queryMap["page"]) + assert.Equal(t, float64(1), pageMap["$gte"], "$gte value should match exactly") + }) + + t.Run("path JSON parsed with exact values", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterPath: `{"$contains":"/webhooks/"}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + pathMap, ok := rules[0]["path"].(map[string]interface{}) + require.True(t, ok, "path should be map[string]interface{}, got %T", rules[0]["path"]) + assert.Equal(t, "/webhooks/", pathMap["$contains"], "$contains value should match exactly") + }) + + t.Run("all four filter flags combined with exact values", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterHeaders: `{"content-type":"application/json"}`, + RuleFilterBody: `{"action":"created"}`, + RuleFilterQuery: `{"verbose":"true"}`, + RuleFilterPath: `{"$startsWith":"/api/v1"}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, "filter", rule["type"]) + + headersMap, ok := rule["headers"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "application/json", headersMap["content-type"]) + + bodyMap, ok := rule["body"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "created", bodyMap["action"]) + + queryMap, ok := rule["query"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "true", queryMap["verbose"]) + + pathMap, ok := rule["path"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "/api/v1", pathMap["$startsWith"]) + }) + + t.Run("JSON round-trip preserves exact structure", func(t *testing.T) { + input := `{"x-shopify-topic":{"$startsWith":"order/"},"x-api-key":{"$eq":"secret123"}}` + flags := connectionRuleFlags{ + RuleFilterHeaders: input, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) - headersMap, isMap := headers.(map[string]interface{}) - assert.True(t, isMap, "headers should be map[string]interface{}, got %T", headers) - assert.Contains(t, headersMap, "x-shopify-topic") + // Marshal the rule to JSON and unmarshal back to verify round-trip + jsonBytes, err := json.Marshal(rules[0]) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(jsonBytes, &parsed)) + + headersMap, ok := parsed["headers"].(map[string]interface{}) + require.True(t, ok) + + topicMap, ok := headersMap["x-shopify-topic"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "order/", topicMap["$startsWith"]) + + apiKeyMap, ok := headersMap["x-api-key"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "secret123", apiKeyMap["$eq"]) }) - t.Run("non-JSON string (JQ expression) should remain a string", func(t *testing.T) { + t.Run("JSON array values parsed correctly", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleFilterBody: `{"tags":["urgent","billing"],"status":{"$in":["active","pending"]}}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + bodyMap, ok := rules[0]["body"].(map[string]interface{}) + require.True(t, ok) + + tags, ok := bodyMap["tags"].([]interface{}) + require.True(t, ok, "tags should be an array, got %T", bodyMap["tags"]) + assert.Equal(t, []interface{}{"urgent", "billing"}, tags) + + statusMap, ok := bodyMap["status"].(map[string]interface{}) + require.True(t, ok) + inArr, ok := statusMap["$in"].([]interface{}) + require.True(t, ok, "$in should be an array, got %T", statusMap["$in"]) + assert.Equal(t, []interface{}{"active", "pending"}, inArr) + }) + + t.Run("non-JSON string should remain a string", func(t *testing.T) { flags := connectionRuleFlags{ RuleFilterHeaders: `.["x-topic"] == "order"`, } @@ -45,37 +173,87 @@ func TestBuildConnectionRulesFilterHeadersJSON(t *testing.T) { require.NoError(t, err) require.Len(t, rules, 1) - filterRule := rules[0] - headers := filterRule["headers"] + headers := rules[0]["headers"] _, isString := headers.(string) assert.True(t, isString, "non-JSON value should remain a string") + assert.Equal(t, `.["x-topic"] == "order"`, headers) }) - t.Run("filter body JSON should also be parsed", func(t *testing.T) { + t.Run("bare JSON primitives should remain as strings", func(t *testing.T) { + for _, input := range []string{`"order"`, `123`, `true`} { + flags := connectionRuleFlags{ + RuleFilterHeaders: input, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + require.Len(t, rules, 1) + + headers := rules[0]["headers"] + _, isString := headers.(string) + assert.True(t, isString, "input %q should remain a string, got %T", input, headers) + assert.Equal(t, input, headers, "value should be unchanged") + } + }) +} + +// TestBuildConnectionRulesTransformEnvJSON verifies that --rule-transform-env +// parses JSON values into objects with exact values preserved. +func TestBuildConnectionRulesTransformEnvJSON(t *testing.T) { + t.Run("env JSON parsed with exact values", func(t *testing.T) { flags := connectionRuleFlags{ - RuleFilterBody: `{"event_type":"payment"}`, + RuleTransformName: "my-transform", + RuleTransformEnv: `{"API_KEY":"sk-test-123","DEBUG":"true","TIMEOUT":"30"}`, } rules, err := buildConnectionRules(&flags) require.NoError(t, err) require.Len(t, rules, 1) - filterRule := rules[0] - body := filterRule["body"] - _, isString := body.(string) - assert.False(t, isString, "body should be a parsed object, not a string") - _, isMap := body.(map[string]interface{}) - assert.True(t, isMap, "body should be map[string]interface{}, got %T", body) + rule := rules[0] + assert.Equal(t, "transform", rule["type"]) + + transformation, ok := rule["transformation"].(map[string]interface{}) + require.True(t, ok, "transformation should be a map") + assert.Equal(t, "my-transform", transformation["name"]) + + env, ok := transformation["env"].(map[string]interface{}) + require.True(t, ok, "env should be a map, got %T", transformation["env"]) + assert.Equal(t, "sk-test-123", env["API_KEY"], "API_KEY should match exactly") + assert.Equal(t, "true", env["DEBUG"], "DEBUG should match exactly") + assert.Equal(t, "30", env["TIMEOUT"], "TIMEOUT should match exactly") + }) + + t.Run("env JSON round-trip preserves exact values", func(t *testing.T) { + flags := connectionRuleFlags{ + RuleTransformName: "my-transform", + RuleTransformEnv: `{"SECRET":"abc123","NESTED":{"key":"val"}}`, + } + rules, err := buildConnectionRules(&flags) + require.NoError(t, err) + + jsonBytes, err := json.Marshal(rules[0]) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(jsonBytes, &parsed)) + + transformation := parsed["transformation"].(map[string]interface{}) + env := transformation["env"].(map[string]interface{}) + assert.Equal(t, "abc123", env["SECRET"]) + + nested, ok := env["NESTED"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "val", nested["key"]) }) } // TestBuildConnectionRulesRetryStatusCodesArray verifies that buildConnectionRules -// produces response_status_codes as a []int array (HTTP status codes are integers). +// produces response_status_codes as a []string array (API RetryRule schema). // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 3. func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { tests := []struct { name string flags connectionRuleFlags - wantCodes []int + wantCodes []string wantCodeCount int wantRuleCount int }{ @@ -87,7 +265,7 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { RuleRetryInterval: 5000, RuleRetryResponseStatusCode: "500,502,503,504", }, - wantCodes: []int{500, 502, 503, 504}, + wantCodes: []string{"500", "502", "503", "504"}, wantCodeCount: 4, wantRuleCount: 1, }, @@ -97,7 +275,7 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { RuleRetryStrategy: "exponential", RuleRetryResponseStatusCode: "500", }, - wantCodes: []int{500}, + wantCodes: []string{"500"}, wantCodeCount: 1, wantRuleCount: 1, }, @@ -107,7 +285,7 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { RuleRetryStrategy: "linear", RuleRetryResponseStatusCode: "500, 502, 503", }, - wantCodes: []int{500, 502, 503}, + wantCodes: []string{"500", "502", "503"}, wantCodeCount: 3, wantRuleCount: 1, }, @@ -145,12 +323,12 @@ func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) { statusCodes, ok := retryRule["response_status_codes"] require.True(t, ok, "response_status_codes should be present") - codesSlice, ok := statusCodes.([]int) - require.True(t, ok, "response_status_codes should be []int, got %T", statusCodes) + codesSlice, ok := statusCodes.([]string) + require.True(t, ok, "response_status_codes should be []string (API schema), got %T", statusCodes) assert.Equal(t, tt.wantCodeCount, len(codesSlice)) assert.Equal(t, tt.wantCodes, codesSlice) - // Verify it serializes to a JSON array of numbers + // Verify it serializes to a JSON array of strings jsonBytes, err := json.Marshal(retryRule) require.NoError(t, err) diff --git a/pkg/cmd/destination_config_json_test.go b/pkg/cmd/destination_config_json_test.go new file mode 100644 index 00000000..f6ecda4a --- /dev/null +++ b/pkg/cmd/destination_config_json_test.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBuildDestinationConfigFromJSONString verifies that --config (JSON string) parses +// into a config map with exact values preserved. +func TestBuildDestinationConfigFromJSONString(t *testing.T) { + t.Run("HTTP config JSON with exact values", func(t *testing.T) { + input := `{"url":"https://api.example.com/hooks","http_method":"PUT","rate_limit":100,"rate_limit_period":"second"}` + config, err := buildDestinationConfigFromFlags(input, "", "", nil) + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, "https://api.example.com/hooks", config["url"]) + assert.Equal(t, "PUT", config["http_method"]) + assert.Equal(t, float64(100), config["rate_limit"]) + assert.Equal(t, "second", config["rate_limit_period"]) + }) + + t.Run("config with auth JSON preserves exact values", func(t *testing.T) { + input := `{"url":"https://api.example.com","auth_type":"BEARER_TOKEN","auth":{"bearer_token":"sk-test-token-xyz"}}` + config, err := buildDestinationConfigFromFlags(input, "", "", nil) + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, "https://api.example.com", config["url"]) + assert.Equal(t, "BEARER_TOKEN", config["auth_type"]) + + auth, ok := config["auth"].(map[string]interface{}) + require.True(t, ok, "auth should be a map, got %T", config["auth"]) + assert.Equal(t, "sk-test-token-xyz", auth["bearer_token"], + "bearer_token value should be exactly 'sk-test-token-xyz'") + }) + + t.Run("config with nested custom signature preserves structure", func(t *testing.T) { + input := `{"url":"https://api.example.com","auth_type":"CUSTOM_SIGNATURE","auth":{"secret":"sig_secret_123","key":"X-Signature"}}` + config, err := buildDestinationConfigFromFlags(input, "", "", nil) + require.NoError(t, err) + require.NotNil(t, config) + + auth, ok := config["auth"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "sig_secret_123", auth["secret"]) + assert.Equal(t, "X-Signature", auth["key"]) + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + _, err := buildDestinationConfigFromFlags(`{broken`, "", "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "--config") + }) +} + +// TestBuildDestinationConfigFromJSONFile verifies that --config-file reads a JSON file +// and produces a config map with exact values preserved. +func TestBuildDestinationConfigFromJSONFile(t *testing.T) { + t.Run("file config JSON with exact values", func(t *testing.T) { + content := `{"url":"https://file-based.example.com/hooks","http_method":"PATCH","rate_limit":50,"rate_limit_period":"minute"}` + tmpFile := filepath.Join(t.TempDir(), "dest-config.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + + config, err := buildDestinationConfigFromFlags("", tmpFile, "", nil) + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, "https://file-based.example.com/hooks", config["url"]) + assert.Equal(t, "PATCH", config["http_method"]) + assert.Equal(t, float64(50), config["rate_limit"]) + assert.Equal(t, "minute", config["rate_limit_period"]) + }) + + t.Run("file with auth config preserves exact values", func(t *testing.T) { + content := `{"url":"https://api.example.com","auth_type":"API_KEY","auth":{"api_key":"key_from_file_789","header_key":"X-API-Key","to":"header"}}` + tmpFile := filepath.Join(t.TempDir(), "dest-auth-config.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + + config, err := buildDestinationConfigFromFlags("", tmpFile, "", nil) + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, "API_KEY", config["auth_type"]) + auth, ok := config["auth"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "key_from_file_789", auth["api_key"]) + assert.Equal(t, "X-API-Key", auth["header_key"]) + assert.Equal(t, "header", auth["to"]) + }) + + t.Run("nonexistent file returns error", func(t *testing.T) { + _, err := buildDestinationConfigFromFlags("", "/nonexistent/path.json", "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-file") + }) + + t.Run("invalid JSON file returns error", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(`{not valid`), 0644)) + + _, err := buildDestinationConfigFromFlags("", tmpFile, "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "config file") + }) +} diff --git a/pkg/cmd/source_config_json_test.go b/pkg/cmd/source_config_json_test.go new file mode 100644 index 00000000..4bb60e39 --- /dev/null +++ b/pkg/cmd/source_config_json_test.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBuildSourceConfigFromJSONString verifies that --config (JSON string) parses +// into a config map with exact values preserved. +func TestBuildSourceConfigFromJSONString(t *testing.T) { + t.Run("simple config JSON with exact values", func(t *testing.T) { + input := `{"allowed_http_methods":["POST","PUT"],"custom_response":{"content_type":"json","body":"{\"ok\":true}"}}` + config, err := buildSourceConfigFromFlags(input, "", nil, "WEBHOOK") + require.NoError(t, err) + require.NotNil(t, config) + + methods, ok := config["allowed_http_methods"].([]interface{}) + require.True(t, ok, "allowed_http_methods should be an array, got %T", config["allowed_http_methods"]) + assert.Equal(t, []interface{}{"POST", "PUT"}, methods) + + customResp, ok := config["custom_response"].(map[string]interface{}) + require.True(t, ok, "custom_response should be a map, got %T", config["custom_response"]) + assert.Equal(t, "json", customResp["content_type"]) + assert.Equal(t, `{"ok":true}`, customResp["body"]) + }) + + t.Run("auth config JSON with exact values", func(t *testing.T) { + input := `{"webhook_secret":"whsec_test_abc123"}` + config, err := buildSourceConfigFromFlags(input, "", nil, "STRIPE") + require.NoError(t, err) + require.NotNil(t, config) + + // normalizeSourceConfigAuth may transform this, but the value should be preserved + // Check that the secret value is present somewhere in the config + auth, hasAuth := config["auth"].(map[string]interface{}) + if hasAuth { + assert.Equal(t, "whsec_test_abc123", auth["webhook_secret_key"], + "webhook_secret_key should be exactly 'whsec_test_abc123'") + } else { + // If not normalized, original key should be present + assert.Equal(t, "whsec_test_abc123", config["webhook_secret"], + "webhook_secret should be exactly 'whsec_test_abc123'") + } + }) + + t.Run("nested config JSON preserves structure", func(t *testing.T) { + input := `{"auth":{"webhook_secret_key":"whsec_nested_123"},"custom_response":{"content_type":"xml","body":""}}` + config, err := buildSourceConfigFromFlags(input, "", nil, "STRIPE") + require.NoError(t, err) + require.NotNil(t, config) + + auth, ok := config["auth"].(map[string]interface{}) + require.True(t, ok, "auth should be a map, got %T", config["auth"]) + assert.Equal(t, "whsec_nested_123", auth["webhook_secret_key"]) + + customResp, ok := config["custom_response"].(map[string]interface{}) + require.True(t, ok, "custom_response should be a map") + assert.Equal(t, "xml", customResp["content_type"]) + assert.Equal(t, "", customResp["body"]) + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + _, err := buildSourceConfigFromFlags(`{invalid`, "", nil, "WEBHOOK") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config") + }) +} + +// TestBuildSourceConfigFromJSONFile verifies that --config-file reads a JSON file +// and produces a config map with exact values preserved. +func TestBuildSourceConfigFromJSONFile(t *testing.T) { + t.Run("file config JSON with exact values", func(t *testing.T) { + content := `{"allowed_http_methods":["GET","POST"],"custom_response":{"content_type":"text","body":"received"}}` + tmpFile := filepath.Join(t.TempDir(), "source-config.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + + config, err := buildSourceConfigFromFlags("", tmpFile, nil, "WEBHOOK") + require.NoError(t, err) + require.NotNil(t, config) + + methods, ok := config["allowed_http_methods"].([]interface{}) + require.True(t, ok, "allowed_http_methods should be an array") + assert.Equal(t, []interface{}{"GET", "POST"}, methods) + + customResp, ok := config["custom_response"].(map[string]interface{}) + require.True(t, ok, "custom_response should be a map") + assert.Equal(t, "text", customResp["content_type"]) + assert.Equal(t, "received", customResp["body"]) + }) + + t.Run("file with auth config preserves exact values", func(t *testing.T) { + content := `{"webhook_secret":"whsec_file_test_456"}` + tmpFile := filepath.Join(t.TempDir(), "source-auth-config.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) + + config, err := buildSourceConfigFromFlags("", tmpFile, nil, "STRIPE") + require.NoError(t, err) + require.NotNil(t, config) + + // After normalization, secret should be preserved + auth, hasAuth := config["auth"].(map[string]interface{}) + if hasAuth { + assert.Equal(t, "whsec_file_test_456", auth["webhook_secret_key"]) + } else { + assert.Equal(t, "whsec_file_test_456", config["webhook_secret"]) + } + }) + + t.Run("nonexistent file returns error", func(t *testing.T) { + _, err := buildSourceConfigFromFlags("", "/nonexistent/path.json", nil, "WEBHOOK") + require.Error(t, err) + assert.Contains(t, err.Error(), "--config-file") + }) + + t.Run("invalid JSON file returns error", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(`{not json`), 0644)) + + _, err := buildSourceConfigFromFlags("", tmpFile, nil, "WEBHOOK") + require.Error(t, err) + assert.Contains(t, err.Error(), "config file") + }) +} diff --git a/test/acceptance/connection_rules_json_test.go b/test/acceptance/connection_rules_json_test.go new file mode 100644 index 00000000..5d783827 --- /dev/null +++ b/test/acceptance/connection_rules_json_test.go @@ -0,0 +1,278 @@ +package acceptance + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConnectionCreateWithRulesJSONExactValues verifies that connection --rules (JSON string) +// sends the correct structure to the API and the returned rules preserve exact values. +func TestConnectionCreateWithRulesJSONExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + t.Run("filter rule with nested JSON values", func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-rules-json-filter-" + timestamp + sourceName := "test-rules-json-src-" + timestamp + destName := "test-rules-json-dst-" + timestamp + + rulesJSON := `[{"type":"filter","headers":{"x-event-type":{"$eq":"order.created"}},"body":{"amount":{"$gte":100},"currency":"USD"}}]` + + var resp map[string]interface{} + err := cli.RunJSON(&resp, + "gateway", "connection", "create", + "--name", connName, + "--source-type", "WEBHOOK", + "--source-name", sourceName, + "--destination-type", "HTTP", + "--destination-name", destName, + "--destination-url", "https://example.com/webhook", + "--rules", rulesJSON, + ) + require.NoError(t, err, "Should create connection with --rules JSON") + + connID, ok := resp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID") + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + rules, ok := resp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array") + + foundFilter := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "filter" { + continue + } + foundFilter = true + + // Verify headers with exact nested values + headersMap, ok := rule["headers"].(map[string]interface{}) + require.True(t, ok, "headers should be a map") + eventTypeMap, ok := headersMap["x-event-type"].(map[string]interface{}) + require.True(t, ok, "x-event-type should be a nested map") + assert.Equal(t, "order.created", eventTypeMap["$eq"], + "$eq value should be exactly 'order.created'") + + // Verify body with exact nested values + bodyMap, ok := rule["body"].(map[string]interface{}) + require.True(t, ok, "body should be a map") + assert.Equal(t, "USD", bodyMap["currency"], + "currency should be exactly 'USD'") + amountMap, ok := bodyMap["amount"].(map[string]interface{}) + require.True(t, ok, "amount should be a nested map") + assert.Equal(t, float64(100), amountMap["$gte"], + "$gte should be exactly 100") + break + } + assert.True(t, foundFilter, "Should have a filter rule") + }) + + t.Run("multiple rules with exact values", func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-rules-json-multi-" + timestamp + sourceName := "test-rules-json-multi-src-" + timestamp + destName := "test-rules-json-multi-dst-" + timestamp + + rulesJSON := `[{"type":"filter","headers":{"content-type":"application/json"}},{"type":"retry","strategy":"linear","count":3,"interval":10000,"response_status_codes":[500,502,503]}]` + + var resp map[string]interface{} + err := cli.RunJSON(&resp, + "gateway", "connection", "create", + "--name", connName, + "--source-type", "WEBHOOK", + "--source-name", sourceName, + "--destination-type", "HTTP", + "--destination-name", destName, + "--destination-url", "https://example.com/webhook", + "--rules", rulesJSON, + ) + require.NoError(t, err, "Should create connection with multiple --rules") + + connID, ok := resp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID") + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + rules, ok := resp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array") + + foundFilter := false + foundRetry := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok { + continue + } + + switch rule["type"] { + case "filter": + foundFilter = true + headersMap, ok := rule["headers"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "application/json", headersMap["content-type"]) + + case "retry": + foundRetry = true + assert.Equal(t, "linear", rule["strategy"]) + assert.Equal(t, float64(3), rule["count"]) + assert.Equal(t, float64(10000), rule["interval"]) + + statusCodes, ok := rule["response_status_codes"] + require.True(t, ok, "response_status_codes should be present") + assertResponseStatusCodesMatch(t, statusCodes, "500", "502", "503") + } + } + assert.True(t, foundFilter, "Should have a filter rule") + assert.True(t, foundRetry, "Should have a retry rule") + }) +} + +// TestConnectionCreateWithRulesFileExactValues verifies that connection --rules-file +// reads JSON from a file and the returned rules preserve exact values. +func TestConnectionCreateWithRulesFileExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-rules-file-" + timestamp + sourceName := "test-rules-file-src-" + timestamp + destName := "test-rules-file-dst-" + timestamp + + rulesContent := `[{"type":"filter","headers":{"x-source":{"$eq":"stripe"}},"body":{"event":{"$startsWith":"payment."}}},{"type":"retry","strategy":"exponential","count":5,"interval":30000,"response_status_codes":[500,502,503,504]}]` + tmpFile := filepath.Join(t.TempDir(), "rules.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(rulesContent), 0644)) + + var resp map[string]interface{} + err := cli.RunJSON(&resp, + "gateway", "connection", "create", + "--name", connName, + "--source-type", "WEBHOOK", + "--source-name", sourceName, + "--destination-type", "HTTP", + "--destination-name", destName, + "--destination-url", "https://example.com/webhook", + "--rules-file", tmpFile, + ) + require.NoError(t, err, "Should create connection with --rules-file") + + connID, ok := resp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID") + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + rules, ok := resp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array") + + foundFilter := false + foundRetry := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok { + continue + } + + switch rule["type"] { + case "filter": + foundFilter = true + + headersMap, ok := rule["headers"].(map[string]interface{}) + require.True(t, ok) + sourceMap, ok := headersMap["x-source"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "stripe", sourceMap["$eq"]) + + bodyMap, ok := rule["body"].(map[string]interface{}) + require.True(t, ok) + eventMap, ok := bodyMap["event"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "payment.", eventMap["$startsWith"]) + + case "retry": + foundRetry = true + assert.Equal(t, "exponential", rule["strategy"]) + assert.Equal(t, float64(5), rule["count"]) + assert.Equal(t, float64(30000), rule["interval"]) + + statusCodes, ok := rule["response_status_codes"] + require.True(t, ok, "response_status_codes should be present") + assertResponseStatusCodesMatch(t, statusCodes, "500", "502", "503", "504") + } + } + assert.True(t, foundFilter, "Should have a filter rule") + assert.True(t, foundRetry, "Should have a retry rule") +} + +// TestConnectionUpsertWithRulesJSONExactValues verifies that connection upsert --rules (JSON) +// sends the correct structure and the returned rules preserve exact values. +func TestConnectionUpsertWithRulesJSONExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-upsert-rules-json-" + timestamp + sourceName := "test-upsert-rules-src-" + timestamp + destName := "test-upsert-rules-dst-" + timestamp + + rulesJSON := `[{"type":"filter","body":{"action":{"$in":["created","updated"]}}},{"type":"delay","delay":5000}]` + + var resp map[string]interface{} + err := cli.RunJSON(&resp, + "gateway", "connection", "upsert", connName, + "--source-type", "WEBHOOK", + "--source-name", sourceName, + "--destination-type", "HTTP", + "--destination-name", destName, + "--destination-url", "https://example.com/webhook", + "--rules", rulesJSON, + ) + require.NoError(t, err, "Should upsert connection with --rules JSON") + + connID, ok := resp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID") + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + rules, ok := resp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array") + + foundFilter := false + foundDelay := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok { + continue + } + + switch rule["type"] { + case "filter": + foundFilter = true + bodyMap, ok := rule["body"].(map[string]interface{}) + require.True(t, ok) + actionMap, ok := bodyMap["action"].(map[string]interface{}) + require.True(t, ok) + inArr, ok := actionMap["$in"].([]interface{}) + require.True(t, ok, "$in should be an array") + assert.Equal(t, []interface{}{"created", "updated"}, inArr) + + case "delay": + foundDelay = true + assert.Equal(t, float64(5000), rule["delay"], "delay should be exactly 5000") + } + } + assert.True(t, foundFilter, "Should have a filter rule") + assert.True(t, foundDelay, "Should have a delay rule") +} diff --git a/test/acceptance/connection_test.go b/test/acceptance/connection_test.go index 7ab68e30..d2c399a6 100644 --- a/test/acceptance/connection_test.go +++ b/test/acceptance/connection_test.go @@ -1274,8 +1274,8 @@ func TestConnectionWithFilterRule(t *testing.T) { rule := getConn.Rules[0] assert.Equal(t, "filter", rule["type"], "Rule type should be filter") - assert.Equal(t, `{"type":"payment"}`, rule["body"], "Filter body should match input") - assert.Equal(t, `{"content-type":"application/json"}`, rule["headers"], "Filter headers should match input") + assertFilterRuleFieldMatches(t, rule["body"], `{"type":"payment"}`, "body") + assertFilterRuleFieldMatches(t, rule["headers"], `{"content-type":"application/json"}`, "headers") t.Logf("Successfully created and verified connection with filter rule: %s", conn.ID) } @@ -1486,7 +1486,7 @@ func TestConnectionWithMultipleRules(t *testing.T) { assert.Equal(t, "retry", getConn.Rules[2]["type"], "Third rule should be retry (logical order)") // Verify filter rule details - assert.Equal(t, `{"type":"payment"}`, getConn.Rules[0]["body"], "Filter should have body expression") + assertFilterRuleFieldMatches(t, getConn.Rules[0]["body"], `{"type":"payment"}`, "body") // Verify delay rule details assert.Equal(t, float64(1000), getConn.Rules[1]["delay"], "Delay should be 1000 milliseconds") @@ -2069,7 +2069,7 @@ func TestConnectionUpsertReplaceRules(t *testing.T) { replacedRule := upserted.Rules[0] assert.Equal(t, "filter", replacedRule["type"], "Rule should now be filter type") assert.NotEqual(t, "retry", replacedRule["type"], "Retry rule should be replaced") - assert.Equal(t, filterBody, replacedRule["body"], "Filter body should match input") + assertFilterRuleFieldMatches(t, replacedRule["body"], filterBody, "body") // Verify source and destination are preserved assert.Equal(t, sourceName, upserted.Source.Name, "Source should be preserved in upsert output") @@ -2083,7 +2083,7 @@ func TestConnectionUpsertReplaceRules(t *testing.T) { assert.Len(t, fetched.Rules, 1, "Should have exactly one rule persisted") fetchedRule := fetched.Rules[0] assert.Equal(t, "filter", fetchedRule["type"], "Persisted rule should be filter type") - assert.Equal(t, filterBody, fetchedRule["body"], "Persisted filter body should match input") + assertFilterRuleFieldMatches(t, fetchedRule["body"], filterBody, "body") t.Logf("Successfully replaced rules via upsert: %s", conn.ID) } @@ -2238,9 +2238,9 @@ func TestConnectionCreateRetryResponseStatusCodes(t *testing.T) { if rule["type"] == "retry" { foundRetry = true - statusCodes, ok := rule["response_status_codes"].([]interface{}) - require.True(t, ok, "response_status_codes should be an array, got: %T (%v)", rule["response_status_codes"], rule["response_status_codes"]) - assert.Len(t, statusCodes, 4, "Should have 4 status codes") + statusCodes, ok := rule["response_status_codes"] + require.True(t, ok, "response_status_codes should be present") + assertResponseStatusCodesMatch(t, statusCodes, "500", "502", "503", "504") break } } diff --git a/test/acceptance/connection_update_test.go b/test/acceptance/connection_update_test.go index aebeb335..61386129 100644 --- a/test/acceptance/connection_update_test.go +++ b/test/acceptance/connection_update_test.go @@ -271,7 +271,7 @@ func TestConnectionUpdateFilterRule(t *testing.T) { for _, rule := range updated.Rules { if rule["type"] == "filter" { foundFilter = true - assert.Equal(t, filterBody, rule["body"], "Filter body should match") + assertFilterRuleFieldMatches(t, rule["body"], filterBody, "body") break } } @@ -351,9 +351,9 @@ func TestConnectionUpdateRetryResponseStatusCodes(t *testing.T) { if rule["type"] == "retry" { foundRetry = true - statusCodes, ok := rule["response_status_codes"].([]interface{}) - require.True(t, ok, "response_status_codes should be an array, got: %T (%v)", rule["response_status_codes"], rule["response_status_codes"]) - assert.Len(t, statusCodes, 3, "Should have 3 status codes") + statusCodes, ok := rule["response_status_codes"] + require.True(t, ok, "response_status_codes should be present") + assertResponseStatusCodesMatch(t, statusCodes, "500", "502", "503") break } } diff --git a/test/acceptance/connection_upsert_test.go b/test/acceptance/connection_upsert_test.go index 56bafefc..3b4e576a 100644 --- a/test/acceptance/connection_upsert_test.go +++ b/test/acceptance/connection_upsert_test.go @@ -1,7 +1,6 @@ package acceptance import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -322,73 +321,6 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { t.Logf("Successfully upserted connection %s with only rule flags, auth preserved", connID) }) - // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 3: - // --rule-retry-response-status-codes must be sent as an array, not a string. - t.Run("UpsertRetryResponseStatusCodesAsArray", func(t *testing.T) { - if testing.Short() { - t.Skip("Skipping acceptance test in short mode") - } - - cli := NewCLIRunner(t) - timestamp := generateTimestamp() - - connName := "test-upsert-statuscodes-" + timestamp - sourceName := "test-upsert-src-sc-" + timestamp - destName := "test-upsert-dst-sc-" + timestamp - - // Create a connection with full source/dest so the upsert provides all required fields - var upsertResp map[string]interface{} - err := cli.RunJSON(&upsertResp, - "gateway", "connection", "upsert", connName, - "--source-name", sourceName, - "--source-type", "WEBHOOK", - "--destination-name", destName, - "--destination-type", "HTTP", - "--destination-url", "https://api.example.com/webhook", - "--rule-retry-strategy", "linear", - "--rule-retry-count", "3", - "--rule-retry-interval", "5000", - "--rule-retry-response-status-codes", "500,502,503,504", - ) - require.NoError(t, err, "Should upsert with retry response status codes as array") - - connID, _ := upsertResp["id"].(string) - t.Cleanup(func() { - if connID != "" { - deleteConnection(t, cli, connID) - } - }) - - // Verify the retry rule has status codes as an array - rules, ok := upsertResp["rules"].([]interface{}) - require.True(t, ok, "Expected rules array") - - foundRetry := false - for _, r := range rules { - rule, ok := r.(map[string]interface{}) - if ok && rule["type"] == "retry" { - foundRetry = true - - statusCodes, ok := rule["response_status_codes"].([]interface{}) - require.True(t, ok, "response_status_codes should be array, got: %T (%v)", rule["response_status_codes"], rule["response_status_codes"]) - assert.Len(t, statusCodes, 4, "Should have 4 status codes") - - codes := make([]string, len(statusCodes)) - for i, c := range statusCodes { - codes[i] = strings.TrimSpace(c.(string)) - } - assert.Contains(t, codes, "500") - assert.Contains(t, codes, "502") - assert.Contains(t, codes, "503") - assert.Contains(t, codes, "504") - break - } - } - assert.True(t, foundRetry, "Should have a retry rule") - - t.Logf("Successfully verified retry status codes sent as array") - }) - // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 2: // Upserting with --source-name alone (without --source-type) should work for // existing connections (the existing type is preserved). @@ -443,9 +375,8 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { }) // Regression test for https://github.com/hookdeck/hookdeck-cli/issues/192: - // --rule-filter-headers (and other filter flags) should store JSON as a parsed - // object, not as an escaped string. - t.Run("FilterHeadersJSONStoredAsObject", func(t *testing.T) { + // --rule-filter-headers should store JSON as a parsed object with exact values preserved. + t.Run("FilterHeadersJSONExactValues", func(t *testing.T) { if testing.Short() { t.Skip("Skipping acceptance test in short mode") } @@ -457,7 +388,6 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { sourceName := "test-filter-src-" + timestamp destName := "test-filter-dst-" + timestamp - // Create a connection using --rule-filter-headers with a JSON object var createResp map[string]interface{} err := cli.RunJSON(&createResp, "gateway", "connection", "upsert", connName, @@ -466,7 +396,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { "--destination-name", destName, "--destination-type", "HTTP", "--destination-url", "https://example.com/webhook", - "--rule-filter-headers", `{"x-shopify-topic":{"$startsWith":"order/"}}`, + "--rule-filter-headers", `{"x-shopify-topic":{"$startsWith":"order/"},"content-type":"application/json"}`, ) require.NoError(t, err, "Should create connection with --rule-filter-headers JSON") @@ -477,7 +407,6 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { deleteConnection(t, cli, connID) }) - // Verify source and destination were created correctly source, ok := createResp["source"].(map[string]interface{}) require.True(t, ok, "Expected source object in response") assert.Equal(t, sourceName, source["name"], "Source name should match") @@ -486,7 +415,6 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { require.True(t, ok, "Expected destination object in response") assert.Equal(t, destName, dest["name"], "Destination name should match") - // Verify the filter rule has headers as a JSON object, not an escaped string rules, ok := createResp["rules"].([]interface{}) require.True(t, ok, "Expected rules array in response") @@ -498,25 +426,25 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { } foundFilter = true - headers := rule["headers"] - _, isString := headers.(string) - assert.False(t, isString, - "--rule-filter-headers should store JSON as an object, not an escaped string; got: %v", headers) + headersMap, isMap := rule["headers"].(map[string]interface{}) + require.True(t, isMap, + "headers should be a JSON object, got %T: %v", rule["headers"], rule["headers"]) + + // Verify exact nested values + topicVal, ok := headersMap["x-shopify-topic"].(map[string]interface{}) + require.True(t, ok, "x-shopify-topic should be a nested object, got %T", headersMap["x-shopify-topic"]) + assert.Equal(t, "order/", topicVal["$startsWith"], + "$startsWith value should be exactly 'order/'") - headersMap, isMap := headers.(map[string]interface{}) - assert.True(t, isMap, - "headers should be a JSON object (map[string]interface{}), got %T", headers) - assert.Contains(t, headersMap, "x-shopify-topic", - "headers object should contain the expected key") + assert.Equal(t, "application/json", headersMap["content-type"], + "content-type value should be exactly 'application/json'") break } assert.True(t, foundFilter, "Should have a filter rule") - - t.Logf("Successfully verified --rule-filter-headers stores JSON as object for connection %s", connID) }) - // Verify that --rule-filter-body JSON is also stored as an object. - t.Run("FilterBodyJSONStoredAsObject", func(t *testing.T) { + // --rule-filter-body should store JSON as a parsed object with exact values preserved. + t.Run("FilterBodyJSONExactValues", func(t *testing.T) { if testing.Short() { t.Skip("Skipping acceptance test in short mode") } @@ -536,7 +464,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { "--destination-name", destName, "--destination-type", "HTTP", "--destination-url", "https://example.com/webhook", - "--rule-filter-body", `{"event_type":"payment"}`, + "--rule-filter-body", `{"event_type":"payment","amount":{"$gte":100}}`, ) require.NoError(t, err, "Should create connection with --rule-filter-body JSON") @@ -558,18 +486,257 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { } foundFilter = true - body := rule["body"] - _, isString := body.(string) - assert.False(t, isString, - "--rule-filter-body should store JSON as an object, not an escaped string; got: %v", body) + bodyMap, isMap := rule["body"].(map[string]interface{}) + require.True(t, isMap, "body should be a JSON object, got %T: %v", rule["body"], rule["body"]) + + assert.Equal(t, "payment", bodyMap["event_type"], + "event_type should be exactly 'payment'") + + amountMap, ok := bodyMap["amount"].(map[string]interface{}) + require.True(t, ok, "amount should be a nested object, got %T", bodyMap["amount"]) + assert.Equal(t, float64(100), amountMap["$gte"], + "$gte value should be exactly 100") + break + } + assert.True(t, foundFilter, "Should have a filter rule") + }) + + // --rule-filter-query should store JSON as a parsed object with exact values preserved. + t.Run("FilterQueryJSONExactValues", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-filter-query-" + timestamp + sourceName := "test-filter-query-src-" + timestamp + destName := "test-filter-query-dst-" + timestamp + + var createResp map[string]interface{} + err := cli.RunJSON(&createResp, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhook", + "--rule-filter-query", `{"status":"active","page":{"$gte":1}}`, + ) + require.NoError(t, err, "Should create connection with --rule-filter-query JSON") + + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in response") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + rules, ok := createResp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array in response") + + foundFilter := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "filter" { + continue + } + foundFilter = true + + queryMap, isMap := rule["query"].(map[string]interface{}) + require.True(t, isMap, "query should be a JSON object, got %T: %v", rule["query"], rule["query"]) + + assert.Equal(t, "active", queryMap["status"], + "status should be exactly 'active'") - bodyMap, isMap := body.(map[string]interface{}) - assert.True(t, isMap, "body should be a JSON object, got %T", body) - assert.Contains(t, bodyMap, "event_type", "body object should contain the expected key") + pageMap, ok := queryMap["page"].(map[string]interface{}) + require.True(t, ok, "page should be a nested object, got %T", queryMap["page"]) + assert.Equal(t, float64(1), pageMap["$gte"], + "$gte value should be exactly 1") break } assert.True(t, foundFilter, "Should have a filter rule") + }) + + // --rule-filter-path should store JSON as a parsed object with exact values preserved. + t.Run("FilterPathJSONExactValues", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-filter-path-" + timestamp + sourceName := "test-filter-path-src-" + timestamp + destName := "test-filter-path-dst-" + timestamp + + var createResp map[string]interface{} + err := cli.RunJSON(&createResp, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhook", + "--rule-filter-path", `{"$contains":"/webhooks/"}`, + ) + require.NoError(t, err, "Should create connection with --rule-filter-path JSON") - t.Logf("Successfully verified --rule-filter-body stores JSON as object for connection %s", connID) + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in response") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + rules, ok := createResp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array in response") + + foundFilter := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "filter" { + continue + } + foundFilter = true + + pathMap, isMap := rule["path"].(map[string]interface{}) + require.True(t, isMap, "path should be a JSON object, got %T: %v", rule["path"], rule["path"]) + + assert.Equal(t, "/webhooks/", pathMap["$contains"], + "$contains value should be exactly '/webhooks/'") + break + } + assert.True(t, foundFilter, "Should have a filter rule") + }) + + // All four filter flags combined should produce a single filter rule with exact values. + t.Run("AllFilterFlagsCombinedExactValues", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-filter-all-" + timestamp + sourceName := "test-filter-all-src-" + timestamp + destName := "test-filter-all-dst-" + timestamp + + var createResp map[string]interface{} + err := cli.RunJSON(&createResp, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhook", + "--rule-filter-headers", `{"content-type":"application/json"}`, + "--rule-filter-body", `{"action":"created"}`, + "--rule-filter-query", `{"verbose":"true"}`, + "--rule-filter-path", `{"$startsWith":"/api/v1"}`, + ) + require.NoError(t, err, "Should create connection with all four filter flags") + + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in response") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + rules, ok := createResp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array in response") + + foundFilter := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "filter" { + continue + } + foundFilter = true + + // Verify headers + headersMap, ok := rule["headers"].(map[string]interface{}) + require.True(t, ok, "headers should be a JSON object, got %T", rule["headers"]) + assert.Equal(t, "application/json", headersMap["content-type"]) + + // Verify body + bodyMap, ok := rule["body"].(map[string]interface{}) + require.True(t, ok, "body should be a JSON object, got %T", rule["body"]) + assert.Equal(t, "created", bodyMap["action"]) + + // Verify query + queryMap, ok := rule["query"].(map[string]interface{}) + require.True(t, ok, "query should be a JSON object, got %T", rule["query"]) + assert.Equal(t, "true", queryMap["verbose"]) + + // Verify path + pathMap, ok := rule["path"].(map[string]interface{}) + require.True(t, ok, "path should be a JSON object, got %T", rule["path"]) + assert.Equal(t, "/api/v1", pathMap["$startsWith"]) + break + } + assert.True(t, foundFilter, "Should have a filter rule") + }) + + // Verify retry status codes are returned as integer array with exact values. + t.Run("RetryStatusCodesExactValues", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-retry-codes-" + timestamp + sourceName := "test-retry-codes-src-" + timestamp + destName := "test-retry-codes-dst-" + timestamp + + var createResp map[string]interface{} + err := cli.RunJSON(&createResp, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhook", + "--rule-retry-strategy", "linear", + "--rule-retry-count", "5", + "--rule-retry-interval", "10000", + "--rule-retry-response-status-codes", "500,502,503,504", + ) + require.NoError(t, err, "Should create connection with retry status codes") + + connID, ok := createResp["id"].(string) + require.True(t, ok && connID != "", "Expected connection ID in response") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + rules, ok := createResp["rules"].([]interface{}) + require.True(t, ok, "Expected rules array in response") + + foundRetry := false + for _, r := range rules { + rule, ok := r.(map[string]interface{}) + if !ok || rule["type"] != "retry" { + continue + } + foundRetry = true + + assert.Equal(t, "linear", rule["strategy"], "strategy should be 'linear'") + assert.Equal(t, float64(5), rule["count"], "count should be 5") + assert.Equal(t, float64(10000), rule["interval"], "interval should be 10000") + + statusCodes, ok := rule["response_status_codes"] + require.True(t, ok, "response_status_codes should be present") + assertResponseStatusCodesMatch(t, statusCodes, "500", "502", "503", "504") + break + } + assert.True(t, foundRetry, "Should have a retry rule") }) } diff --git a/test/acceptance/destination_config_json_test.go b/test/acceptance/destination_config_json_test.go new file mode 100644 index 00000000..68370901 --- /dev/null +++ b/test/acceptance/destination_config_json_test.go @@ -0,0 +1,148 @@ +package acceptance + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDestinationCreateWithConfigJSONExactValues verifies that destination --config (JSON string) +// sends the correct structure to the API and the returned resource preserves exact values. +func TestDestinationCreateWithConfigJSONExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + t.Run("HTTP destination with url and http_method config", func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-cfg-json-" + timestamp + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--config", `{"url":"https://api.example.com/webhooks","http_method":"PUT"}`, + ) + require.NoError(t, err, "Should create destination with --config JSON") + + dstID, ok := resp["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + assert.Equal(t, name, resp["name"], "Destination name should match exactly") + assert.Equal(t, "HTTP", resp["type"], "Destination type should be HTTP") + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config object in response") + assert.Equal(t, "https://api.example.com/webhooks", config["url"], + "URL should match exactly") + assert.Equal(t, "PUT", config["http_method"], + "http_method should be 'PUT'") + }) + + t.Run("HTTP destination with rate limit config", func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-cfg-rate-" + timestamp + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--config", `{"url":"https://api.example.com/hooks","rate_limit":100,"rate_limit_period":"second"}`, + ) + require.NoError(t, err, "Should create destination with rate limit config") + + dstID, ok := resp["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config object in response") + assert.Equal(t, "https://api.example.com/hooks", config["url"]) + assert.Equal(t, float64(100), config["rate_limit"], + "rate_limit should be exactly 100") + assert.Equal(t, "second", config["rate_limit_period"], + "rate_limit_period should be 'second'") + }) +} + +// TestDestinationCreateWithConfigFileExactValues verifies that destination --config-file +// reads JSON from a file and the returned resource preserves exact values. +func TestDestinationCreateWithConfigFileExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-cfg-file-" + timestamp + + configContent := `{"url":"https://file-config.example.com/hooks","http_method":"PATCH"}` + tmpFile := filepath.Join(t.TempDir(), "dest-config.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(configContent), 0644)) + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--config-file", tmpFile, + ) + require.NoError(t, err, "Should create destination with --config-file") + + dstID, ok := resp["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config object in response") + assert.Equal(t, "https://file-config.example.com/hooks", config["url"]) + assert.Equal(t, "PATCH", config["http_method"]) +} + +// TestDestinationUpsertWithConfigJSONExactValues verifies that destination upsert --config (JSON) +// creates/updates with exact values preserved in the API response. +func TestDestinationUpsertWithConfigJSONExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-upsert-cfg-" + timestamp + + // Create via upsert + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "destination", "upsert", name, + "--type", "HTTP", + "--config", `{"url":"https://upsert-config.example.com/v1","http_method":"POST"}`, + ) + require.NoError(t, err, "Should upsert destination with --config JSON") + + dstID, ok := resp["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config in response") + assert.Equal(t, "https://upsert-config.example.com/v1", config["url"]) + assert.Equal(t, "POST", config["http_method"]) + + // Update via upsert with new config + var resp2 map[string]interface{} + err = cli.RunJSON(&resp2, "gateway", "destination", "upsert", name, + "--config", `{"url":"https://upsert-config.example.com/v2","http_method":"PUT"}`, + ) + require.NoError(t, err, "Should upsert destination with updated --config JSON") + + config2, ok := resp2["config"].(map[string]interface{}) + require.True(t, ok, "Expected config in upsert update response") + assert.Equal(t, "https://upsert-config.example.com/v2", config2["url"], + "URL should be updated to v2") + assert.Equal(t, "PUT", config2["http_method"], + "http_method should be updated to PUT") +} diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index 5ed647bd..2c32b637 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -194,6 +195,47 @@ func (r *CLIRunner) RunJSON(result interface{}, args ...string) error { return nil } +// assertFilterRuleFieldMatches asserts that a filter rule field (body or headers) matches the expected JSON. +// The API may return the field as either a string or a parsed map; both are accepted. +func assertFilterRuleFieldMatches(t *testing.T, actual interface{}, expectedJSON string, fieldName string) { + t.Helper() + var expectedMap map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(expectedJSON), &expectedMap), "expectedJSON should be valid JSON") + var actualMap map[string]interface{} + switch v := actual.(type) { + case string: + require.NoError(t, json.Unmarshal([]byte(v), &actualMap), "actual string should be valid JSON") + case map[string]interface{}: + actualMap = v + default: + t.Fatalf("%s should be string or map, got %T", fieldName, actual) + } + assert.Equal(t, expectedMap, actualMap, "%s should match expected JSON", fieldName) +} + +// assertResponseStatusCodesMatch asserts that response_status_codes from the API match expected values. +// The API may return codes as strings or numbers; both are accepted. +func assertResponseStatusCodesMatch(t *testing.T, statusCodes interface{}, expected ...string) { + t.Helper() + slice, ok := statusCodes.([]interface{}) + require.True(t, ok, "response_status_codes should be an array, got %T", statusCodes) + require.Len(t, slice, len(expected), "response_status_codes length") + for i, exp := range expected { + var actual string + switch v := slice[i].(type) { + case string: + actual = v + case float64: + actual = fmt.Sprintf("%.0f", v) + case int: + actual = fmt.Sprintf("%d", v) + default: + actual = fmt.Sprintf("%v", slice[i]) + } + assert.Equal(t, exp, actual, "response_status_codes[%d]", i) + } +} + // generateTimestamp returns a timestamp string in the format YYYYMMDDHHMMSS plus microseconds // This is used for creating unique test resource names func generateTimestamp() string { diff --git a/test/acceptance/source_config_json_test.go b/test/acceptance/source_config_json_test.go new file mode 100644 index 00000000..d52acdbe --- /dev/null +++ b/test/acceptance/source_config_json_test.go @@ -0,0 +1,138 @@ +package acceptance + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSourceCreateWithConfigJSONExactValues verifies that source --config (JSON string) +// sends the correct structure to the API and the returned resource preserves exact values. +func TestSourceCreateWithConfigJSONExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + t.Run("STRIPE source with webhook_secret config", func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-cfg-json-" + timestamp + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "source", "create", + "--name", name, + "--type", "STRIPE", + "--config", `{"webhook_secret":"whsec_exact_test_123"}`, + ) + require.NoError(t, err, "Should create source with --config JSON") + + srcID, ok := resp["id"].(string) + require.True(t, ok && srcID != "", "Expected source ID") + t.Cleanup(func() { deleteSource(t, cli, srcID) }) + + assert.Equal(t, name, resp["name"], "Source name should match exactly") + assert.Equal(t, "STRIPE", resp["type"], "Source type should be STRIPE") + }) + + t.Run("WEBHOOK source with allowed_http_methods config", func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-cfg-methods-" + timestamp + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "source", "create", + "--name", name, + "--type", "WEBHOOK", + "--config", `{"allowed_http_methods":["POST","PUT"]}`, + ) + require.NoError(t, err, "Should create source with allowed_http_methods config") + + srcID, ok := resp["id"].(string) + require.True(t, ok && srcID != "", "Expected source ID") + t.Cleanup(func() { deleteSource(t, cli, srcID) }) + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config object in response") + + methods, ok := config["allowed_http_methods"].([]interface{}) + require.True(t, ok, "allowed_http_methods should be an array, got %T", config["allowed_http_methods"]) + assert.Contains(t, methods, "POST", "Should contain POST") + assert.Contains(t, methods, "PUT", "Should contain PUT") + }) +} + +// TestSourceCreateWithConfigFileExactValues verifies that source --config-file +// reads JSON from a file and the returned resource preserves exact values. +func TestSourceCreateWithConfigFileExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-cfg-file-" + timestamp + + configContent := `{"allowed_http_methods":["GET","POST","PUT"]}` + tmpFile := filepath.Join(t.TempDir(), "source-config.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(configContent), 0644)) + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "source", "create", + "--name", name, + "--type", "WEBHOOK", + "--config-file", tmpFile, + ) + require.NoError(t, err, "Should create source with --config-file") + + srcID, ok := resp["id"].(string) + require.True(t, ok && srcID != "", "Expected source ID") + t.Cleanup(func() { deleteSource(t, cli, srcID) }) + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config object in response") + + methods, ok := config["allowed_http_methods"].([]interface{}) + require.True(t, ok, "allowed_http_methods should be an array") + assert.Len(t, methods, 3, "Should have exactly 3 HTTP methods") + assert.Contains(t, methods, "GET") + assert.Contains(t, methods, "POST") + assert.Contains(t, methods, "PUT") +} + +// TestSourceUpsertWithConfigJSONExactValues verifies that source upsert --config (JSON) +// creates/updates with exact values preserved in the API response. +func TestSourceUpsertWithConfigJSONExactValues(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-upsert-cfg-" + timestamp + + var resp map[string]interface{} + err := cli.RunJSON(&resp, "gateway", "source", "upsert", name, + "--type", "WEBHOOK", + "--config", `{"allowed_http_methods":["POST"],"custom_response":{"content_type":"json","body":"{\"status\":\"ok\"}"}}`, + ) + require.NoError(t, err, "Should upsert source with --config JSON") + + srcID, ok := resp["id"].(string) + require.True(t, ok && srcID != "", "Expected source ID") + t.Cleanup(func() { deleteSource(t, cli, srcID) }) + + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "Expected config in response") + + methods, ok := config["allowed_http_methods"].([]interface{}) + require.True(t, ok, "allowed_http_methods should be an array") + assert.Equal(t, []interface{}{"POST"}, methods) + + customResp, ok := config["custom_response"].(map[string]interface{}) + require.True(t, ok, "custom_response should be a map, got %T", config["custom_response"]) + assert.Equal(t, "json", customResp["content_type"], "content_type should be 'json'") + assert.Equal(t, `{"status":"ok"}`, customResp["body"], "body should match exactly") +}