From 948f9ce2e5fe6be15639273dab4e43da11e68582 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 17:13:26 +0100 Subject: [PATCH 01/16] fix: stop CLI destinations swallowing delivery policy and --config path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by exercising a release-candidate build against the API. A CLI destination carries no delivery_policy in the API schema. Passing --rate-limit or --delivery-group-* with --type CLI was accepted, sent, and discarded server-side: exit 0, and a read-back showing no policy at all. The flags looked applied and never took effect. Reject the combination instead, naming the --destination- prefixed spelling when it came from a connection. Separately, --cli-path is declared with a default of "/" on create (but "" on update and upsert), so the "explicit flag wins" branch fired even when the user never passed it — overwriting a path supplied via --config. Compare against cobra's Changed rather than "", so the advertised default stays in --help and only the --config case changes. The path defaulting is now one helper shared by create and upsert, since the asymmetry between them is what produced the bug. Verified on the wire by pointing the build at a local capture server via --api-base and reading the request bytes, rather than inferring from read-backs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/connection_create.go | 3 + pkg/cmd/destination_cli_type_test.go | 82 ++++++++++++++++++++++++++++ pkg/cmd/destination_common.go | 31 +++++++++++ pkg/cmd/destination_create.go | 10 ++-- pkg/cmd/destination_upsert.go | 4 +- 5 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/destination_cli_type_test.go diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index ac84c63e..ae61b43e 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -620,6 +620,9 @@ func (cc *connectionCreateCmd) buildDestinationConfig() (map[string]interface{}, if err != nil { return nil, err } + if err := rejectDeliveryPolicyForCLI(cc.destinationType, policy, "destination-"); err != nil { + return nil, err + } mergeDeliveryPolicy(config, policy) if len(config) == 0 { diff --git a/pkg/cmd/destination_cli_type_test.go b/pkg/cmd/destination_cli_type_test.go new file mode 100644 index 00000000..93053cc5 --- /dev/null +++ b/pkg/cmd/destination_cli_type_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestApplyCLIPath pins the precedence between --cli-path and a path supplied +// via --config. Create previously overwrote the --config value with "/". +func TestApplyCLIPath(t *testing.T) { + t.Run("--config path survives when --cli-path is absent", func(t *testing.T) { + config := map[string]interface{}{"path": "/from-config"} + applyCLIPath(config, "", true) + assert.Equal(t, "/from-config", config["path"]) + }) + + t.Run("--cli-path wins over --config", func(t *testing.T) { + config := map[string]interface{}{"path": "/from-config"} + applyCLIPath(config, "/from-flag", true) + assert.Equal(t, "/from-flag", config["path"]) + }) + + t.Run("create defaults to / when neither is given", func(t *testing.T) { + config := map[string]interface{}{} + applyCLIPath(config, "", true) + assert.Equal(t, "/", config["path"]) + }) + + t.Run("upsert leaves path absent when neither is given", func(t *testing.T) { + config := map[string]interface{}{} + applyCLIPath(config, "", false) + _, ok := config["path"] + assert.False(t, ok, "upsert must not invent a path, or a partial update would reset it") + }) +} + +// TestRejectDeliveryPolicyForCLI pins that delivery-policy flags are refused for +// CLI destinations. The API accepts the request and discards the policy, so +// without this the flags look applied but never take effect. +func TestRejectDeliveryPolicyForCLI(t *testing.T) { + policy := map[string]interface{}{"rate": 100, "period": "minute"} + + t.Run("CLI destination is rejected", func(t *testing.T) { + err := rejectDeliveryPolicyForCLI("CLI", policy, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--rate-limit") + assert.Contains(t, err.Error(), "CLI destinations") + }) + + t.Run("connection flag prefix is named in the message", func(t *testing.T) { + err := rejectDeliveryPolicyForCLI("cli", policy, "destination-") + require.Error(t, err) + assert.Contains(t, err.Error(), "--destination-rate-limit") + }) + + t.Run("HTTP and MOCK_API are unaffected", func(t *testing.T) { + assert.NoError(t, rejectDeliveryPolicyForCLI("HTTP", policy, "")) + assert.NoError(t, rejectDeliveryPolicyForCLI("MOCK_API", policy, "")) + }) + + t.Run("CLI without a policy is fine", func(t *testing.T) { + assert.NoError(t, rejectDeliveryPolicyForCLI("CLI", map[string]interface{}{}, "")) + }) +} + +// TestBuildDestinationConfigRejectsDeliveryPolicyForCLI covers the funnel that +// destination create/update/upsert all go through. +func TestBuildDestinationConfigRejectsDeliveryPolicyForCLI(t *testing.T) { + flags := &destinationConfigFlags{RateLimit: 100, RateLimitPeriod: "minute"} + _, err := buildDestinationConfigFromIndividualFlags("CLI", flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "CLI destinations") + + // Same flags on HTTP still build a policy. + config, err := buildDestinationConfigFromIndividualFlags("HTTP", flags) + require.NoError(t, err) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, 100, policy["rate"]) +} diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index 47a7eff7..4d3673e2 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -134,6 +134,34 @@ func buildDeliveryPolicy(rate int, period, groupKey string, groupRate int, group return policy, nil } +// applyCLIPath sets the path for a CLI destination. An explicit --cli-path wins; +// otherwise a path already supplied via --config is left alone. withDefault adds +// the "/" default, which only create does — upsert leaves the field absent so the +// stored value survives a partial update. +func applyCLIPath(config map[string]interface{}, cliPath string, withDefault bool) { + if cliPath != "" { + config["path"] = cliPath + return + } + if _, ok := config["path"]; ok { + return + } + if withDefault { + config["path"] = "/" + } +} + +// rejectDeliveryPolicyForCLI refuses delivery-policy flags on a CLI destination. +// CLI destinations carry no delivery_policy in the API schema: the request is +// accepted and the policy discarded, so without this the flags look applied and +// never take effect. +func rejectDeliveryPolicyForCLI(destType string, policy map[string]interface{}, flagPrefix string) error { + if len(policy) == 0 || strings.ToUpper(destType) != "CLI" { + return nil + } + return fmt.Errorf("--%srate-limit and --%sdelivery-group-* are not supported for CLI destinations", flagPrefix, flagPrefix) +} + func mergeDeliveryPolicy(config map[string]interface{}, policy map[string]interface{}) { if len(policy) == 0 { return @@ -235,6 +263,9 @@ func buildDestinationConfigFromIndividualFlags(destType string, f *destinationCo if err != nil { return nil, err } + if err := rejectDeliveryPolicyForCLI(destType, policy, ""); err != nil { + return nil, err + } mergeDeliveryPolicy(config, policy) switch strings.ToUpper(destType) { diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index 8bf53d47..3a536346 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -121,11 +121,13 @@ func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args config["url"] = dc.url } if t == "CLI" { - path := dc.cliPath - if path == "" { - path = "/" + // --cli-path defaults to "/", so compare against Changed rather than "": + // an unset flag must not overwrite a path supplied via --config. + cliPath := dc.cliPath + if !cmd.Flags().Changed("cli-path") { + cliPath = "" } - config["path"] = path + applyCLIPath(config, cliPath, true) } req := &hookdeck.DestinationCreateRequest{ diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index dafe0770..bd1f4275 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -111,8 +111,8 @@ func (dc *destinationUpsertCmd) runDestinationUpsertCmd(cmd *cobra.Command, args if t == "HTTP" && dc.url != "" { config["url"] = dc.url } - if t == "CLI" && dc.cliPath != "" { - config["path"] = dc.cliPath + if t == "CLI" { + applyCLIPath(config, dc.cliPath, false) } req := &hookdeck.DestinationCreateRequest{ From f1fb134e177c4d348bf5a2a6e0e8261f71581ebe Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 17:13:38 +0100 Subject: [PATCH 02/16] fix: make advertised metrics measures and dimensions actually work Three defects found by exercising a release-candidate build against the API. --measures pending only routed to the pending-timeseries endpoint when --granularity was also set; otherwise it fell through to the default events route, which rejects the measure. The API treats granularity as optional on that route, so gating the routing on it was wrong. Route on the measure alone. --measures queue_depth is advertised in --help but is not in the endpoint's enum, which accepts max_depth and max_age only, so it could never succeed. Translate it to max_depth on the wire, mirroring the existing pending -> count translation, so the documented flag works. (If the intent was to drop the spelling instead, removing it from the measures list is the one-line alternative.) --dimensions and --status advertised one generic vocabulary on all four subcommands, and it is wrong on three: passing a suggested dimension returned a raw 422. Each route now advertises the set it accepts, alongside the filter matrix that already gates which flags exist. The metrics example in `gateway --help` was also missing the required --measures, so copying it verbatim failed. Vocabularies cross-checked against the API's OpenAPI document per route. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- REFERENCE.md | 2 +- pkg/cmd/gateway.go | 2 +- pkg/cmd/metrics.go | 6 ++--- pkg/cmd/metrics_attempts.go | 2 +- pkg/cmd/metrics_events.go | 33 ++++++++++++++++++++++---- pkg/cmd/metrics_events_routing_test.go | 22 +++++++++++++++++ pkg/cmd/metrics_requests.go | 2 +- pkg/cmd/metrics_transformations.go | 2 +- pkg/hookdeck/metrics_filters.go | 17 +++++++++++++ 9 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 pkg/cmd/metrics_events_routing_test.go diff --git a/REFERENCE.md b/REFERENCE.md index 80486faf..8692f85a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -262,7 +262,7 @@ hookdeck gateway connection list hookdeck gateway source create --name my-source --type WEBHOOK # Query event metrics -hookdeck gateway metrics events --start 2026-01-01T00:00:00Z --end 2026-02-01T00:00:00Z +hookdeck gateway metrics events --start 2026-01-01T00:00:00Z --end 2026-02-01T00:00:00Z --measures count # Start the MCP server for AI agent access hookdeck gateway mcp diff --git a/pkg/cmd/gateway.go b/pkg/cmd/gateway.go index ca4684b7..0b8738cc 100644 --- a/pkg/cmd/gateway.go +++ b/pkg/cmd/gateway.go @@ -88,7 +88,7 @@ The gateway command group provides full access to all Event Gateway resources.`, hookdeck gateway source create --name my-source --type WEBHOOK # Query event metrics - hookdeck gateway metrics events --start 2026-01-01T00:00:00Z --end 2026-02-01T00:00:00Z + hookdeck gateway metrics events --start 2026-01-01T00:00:00Z --end 2026-02-01T00:00:00Z --measures count # Start the MCP server for AI agent access hookdeck gateway mcp`, diff --git a/pkg/cmd/metrics.go b/pkg/cmd/metrics.go index a4387cbb..c99b71d8 100644 --- a/pkg/cmd/metrics.go +++ b/pkg/cmd/metrics.go @@ -70,12 +70,12 @@ type metricsCommonFlags struct { // addMetricsCommonFlags adds the time-range flags every metrics subcommand // takes, plus only those filter flags the endpoint honours. -func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags, filters hookdeck.MetricsFilters) { +func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags, filters hookdeck.MetricsFilters, dimensions, statusValues string) { cmd.Flags().StringVar(&f.start, "start", "", "Start of time range (ISO 8601 date-time, required)") cmd.Flags().StringVar(&f.end, "end", "", "End of time range (ISO 8601 date-time, required)") cmd.Flags().StringVar(&f.granularity, "granularity", "", granularityHelp) cmd.Flags().StringVar(&f.measures, "measures", "", "Comma-separated list of measures to return") - cmd.Flags().StringVar(&f.dimensions, "dimensions", "", "Comma-separated dimensions to group by (e.g. connection_id, source_id, destination_id, delivery_group, status)") + cmd.Flags().StringVar(&f.dimensions, "dimensions", "", "Comma-separated dimensions to group by (one of: "+dimensions+")") if filters.SourceID { cmd.Flags().StringVar(&f.sourceID, "source-id", "", "Filter by source ID") } @@ -89,7 +89,7 @@ func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags, filters ho cmd.Flags().StringVar(&f.connectionID, "connection-id", "", "Filter by connection ID") } if filters.Status { - cmd.Flags().StringVar(&f.status, "status", "", "Filter by status (e.g. SUCCESSFUL, FAILED)") + cmd.Flags().StringVar(&f.status, "status", "", "Filter by status (one of: "+statusValues+")") } if filters.IssueID { cmd.Flags().StringVar(&f.issueID, "issue-id", "", "Filter by issue ID (required for per-issue metrics, e.g. when using --dimensions issue_id)") diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go index 7198a932..9d83a26e 100644 --- a/pkg/cmd/metrics_attempts.go +++ b/pkg/cmd/metrics_attempts.go @@ -24,7 +24,7 @@ func newMetricsAttemptsCmd() *metricsAttemptsCmd { Long: LongBeta(`Query metrics for delivery attempts (latency, success/failure). Measures: ` + metricsAttemptsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters, hookdeck.AttemptMetricsDimensions, hookdeck.EventStatusValues) return c } diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index a295305e..2b76ad52 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -33,7 +33,7 @@ Measures: ` + metricsEventsMeasures + `. Dimensions: ` + metricsEventsDimensions + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.EventMetricsFilters) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.EventMetricsFilters, hookdeck.EventMetricsDimensions, hookdeck.EventStatusValues) return c } @@ -64,6 +64,24 @@ func hasDimension(params hookdeck.MetricsQueryParams, name string) bool { return false } +// translateQueueDepthMeasures maps the CLI's "queue_depth" onto the API's +// "max_depth", dropping a duplicate if both were requested. +func translateQueueDepthMeasures(measures []string) []string { + out := make([]string, 0, len(measures)) + seen := make(map[string]bool, len(measures)) + for _, m := range measures { + if m == "queue_depth" { + m = "max_depth" + } + if seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + return out +} + // queryEventMetricsConsolidated routes to the correct underlying API endpoint // based on the requested measures and dimensions. func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, params hookdeck.MetricsQueryParams) (hookdeck.MetricsResponse, error) { @@ -73,11 +91,18 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if err := rejectUnsupportedFilters(params, hookdeck.QueueDepthRouteFilters, "queue depth metrics"); err != nil { return nil, err } - return client.QueryQueueDepth(ctx, params) + // The endpoint accepts max_depth and max_age only. "queue_depth" is our own + // spelling for the route, advertised in --help, so translate it rather than + // letting the API reject a measure we told the user to pass. + queueParams := params + queueParams.Measures = translateQueueDepthMeasures(params.Measures) + return client.QueryQueueDepth(ctx, queueParams) } - // 2. If measures include "pending" with granularity → QueryEventsPendingTimeseries + // 2. If measures include "pending" → QueryEventsPendingTimeseries. // API expects measures[]=count; "pending" is only used for routing. - if hasMeasure(params, map[string]bool{"pending": true}) && params.Granularity != "" { + // Granularity is optional on this route, so it must not gate the routing: + // gating it sent "pending" to the default endpoint, which rejects the measure. + if hasMeasure(params, map[string]bool{"pending": true}) { if err := rejectUnsupportedFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (--measures pending)"); err != nil { return nil, err } diff --git a/pkg/cmd/metrics_events_routing_test.go b/pkg/cmd/metrics_events_routing_test.go new file mode 100644 index 00000000..2df00af8 --- /dev/null +++ b/pkg/cmd/metrics_events_routing_test.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestTranslateQueueDepthMeasures pins the mapping from the CLI's advertised +// "queue_depth" onto the measures the queue-depth endpoint actually accepts +// (max_depth, max_age). +func TestTranslateQueueDepthMeasures(t *testing.T) { + assert.Equal(t, []string{"max_depth"}, translateQueueDepthMeasures([]string{"queue_depth"})) + assert.Equal(t, []string{"max_depth"}, translateQueueDepthMeasures([]string{"max_depth"})) + assert.Equal(t, []string{"max_age"}, translateQueueDepthMeasures([]string{"max_age"})) + + // Both spellings collapse to one measure rather than sending it twice. + assert.Equal(t, []string{"max_depth"}, translateQueueDepthMeasures([]string{"queue_depth", "max_depth"})) + assert.Equal(t, []string{"max_depth", "max_age"}, translateQueueDepthMeasures([]string{"queue_depth", "max_age"})) + + assert.Empty(t, translateQueueDepthMeasures(nil)) +} diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go index 4b65ea7a..351a7769 100644 --- a/pkg/cmd/metrics_requests.go +++ b/pkg/cmd/metrics_requests.go @@ -24,7 +24,7 @@ func newMetricsRequestsCmd() *metricsRequestsCmd { Long: LongBeta(`Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.RequestMetricsFilters) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.RequestMetricsFilters, hookdeck.RequestMetricsDimensions, hookdeck.RequestStatusValues) return c } diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go index 58c498e3..c63a2258 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -24,7 +24,7 @@ func newMetricsTransformationsCmd() *metricsTransformationsCmd { Long: LongBeta(`Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters, hookdeck.TransformationMetricsDimensions, hookdeck.EventStatusValues) return c } diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index eccb9430..d783b5b8 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -85,3 +85,20 @@ var MCPFilterNames = MetricsFilterNames{ IssueID: "issue_id", DeliveryGroup: "delivery_group", } + +// Dimension and status vocabularies per metrics route. These differ sharply +// between endpoints, so --help must not advertise one generic list: naming a +// dimension the route does not accept sends the user into an API 422. +const ( + RequestMetricsDimensions = "source_id, rejection_cause, status, bulk_retry_ids, events_count, ignored_count" + AttemptMetricsDimensions = "destination_id, delivery_group, event_id, status, error_code, bulk_retry_id, trigger" + TransformationMetricsDimensions = "transformation_id, webhook_id, log_level, issue_id" + EventMetricsDimensions = "source_id, destination_id, connection_id, delivery_group, status, issue_id" +) + +// Status vocabularies. Request events are accepted or rejected at the edge; +// events and attempts carry a delivery status. +const ( + RequestStatusValues = "ACCEPTED, REJECTED" + EventStatusValues = "SUCCESSFUL, FAILED, QUEUED, PAUSED" +) From 6827c44bb9e21b609474b44e1c0eb8f87042d9b3 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 17:30:02 +0100 Subject: [PATCH 03/16] fix: act on the review of this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects the review caught in the first two commits, plus tests that actually pin the fixes. connection upsert has a second destination-building path, taken when the connection already exists, which built a delivery policy with no CLI guard. The bug this PR set out to fix was still reachable through it. The status vocabulary added for --help was itself wrong. Events accept SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED and CANCELLED; the constant advertised PAUSED, which the API rejects, and omitted three real values. The repo already had the right list in event_list.go and the MCP tools. Attempts accept SUCCESSFUL and FAILED only, so they now get their own constant rather than sharing the events one — a single shared vocabulary across routes with different enums is the defect this commit series is meant to remove. Transformations register no --status flag at all, so they are passed none. Verified against the live API: all six event values query, PAUSED 422s. The MCP layer carried both metrics bugs the CLI had just fixed, contradicting the "shared so they cannot drift apart" comment above the filter matrix. The queue-depth translation moves to pkg/hookdeck and both callers use it, and the granularity gate is gone from the MCP routing too. Also: metrics events printed two conflicting dimension lists on one help page, so the local constant now derives from the shared one; --measures is marked required, since every endpoint rejects a request without it and MCP already enforced it; and connection upsert no longer resets an existing CLI destination's path to "/", which its own comment said it should not do. Every fix in this PR was checked by reverting it and confirming the relevant test fails. Five previously had no such coverage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/connection_upsert.go | 12 +++- pkg/cmd/destination_cli_type_test.go | 94 ++++++++++++++++++++++++++ pkg/cmd/destination_common.go | 10 +++ pkg/cmd/destination_create.go | 8 +-- pkg/cmd/metrics.go | 3 + pkg/cmd/metrics_attempts.go | 2 +- pkg/cmd/metrics_events.go | 22 +----- pkg/cmd/metrics_events_routing_test.go | 85 ++++++++++++++++++++--- pkg/cmd/metrics_transformations.go | 2 +- pkg/gateway/mcp/tool_metrics.go | 8 ++- pkg/hookdeck/metrics_filters.go | 22 +++++- 11 files changed, 223 insertions(+), 45 deletions(-) diff --git a/pkg/cmd/connection_upsert.go b/pkg/cmd/connection_upsert.go index 4b156461..d1f7aabd 100644 --- a/pkg/cmd/connection_upsert.go +++ b/pkg/cmd/connection_upsert.go @@ -468,8 +468,13 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection, if cu.destinationType == "" && isUpdate && existing != nil && existing.Destination != nil { cu.destinationType = existing.Destination.Type } - // Default CLI path to "/" for new CLI destinations when not explicitly set - if strings.ToUpper(cu.destinationType) == "CLI" && cu.destinationCliPath == "" { + // Default CLI path to "/" for new CLI destinations when not explicitly + // set. An existing CLI destination keeps its stored path: destinationType + // was just filled in from it above, so without the isUpdate check this + // rewrites /webhooks to / on every upsert that omits the flag. + existingCLIDest := isUpdate && existing != nil && existing.Destination != nil && + strings.ToUpper(existing.Destination.Type) == "CLI" + if strings.ToUpper(cu.destinationType) == "CLI" && cu.destinationCliPath == "" && !existingCLIDest { cu.destinationCliPath = "/" } destinationInput, err := cu.buildDestinationInput() @@ -611,6 +616,9 @@ func (cu *connectionUpsertCmd) buildDestinationInputForUpdate(existingDest *hook if err != nil { return nil, err } + if err := rejectDeliveryPolicyForCLI(existingDest.Type, policy, "destination-"); err != nil { + return nil, err + } mergeDeliveryPolicy(destConfig, policy) // Apply authentication config if provided diff --git a/pkg/cmd/destination_cli_type_test.go b/pkg/cmd/destination_cli_type_test.go index 93053cc5..6bc97f2a 100644 --- a/pkg/cmd/destination_cli_type_test.go +++ b/pkg/cmd/destination_cli_type_test.go @@ -3,6 +3,10 @@ package cmd import ( "testing" + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -80,3 +84,93 @@ func TestBuildDestinationConfigRejectsDeliveryPolicyForCLI(t *testing.T) { require.True(t, ok) assert.Equal(t, 100, policy["rate"]) } + +// TestCLIPathFromFlags pins the fix for --cli-path's "/" default overwriting a +// path supplied via --config. Comparing the value against "" is not enough, +// because on create the flag is never empty. +func TestCLIPathFromFlags(t *testing.T) { + newCmd := func() *cobra.Command { + cmd := &cobra.Command{Use: "create"} + var cliPath string + cmd.Flags().StringVar(&cliPath, "cli-path", "/", "Path for CLI destinations") + return cmd + } + + t.Run("unset flag yields no path even though it defaults to /", func(t *testing.T) { + cmd := newCmd() + require.NoError(t, cmd.ParseFlags([]string{})) + assert.Equal(t, "", cliPathFromFlags(cmd, "/")) + }) + + t.Run("explicitly passed flag is returned", func(t *testing.T) { + cmd := newCmd() + require.NoError(t, cmd.ParseFlags([]string{"--cli-path", "/hooks"})) + assert.Equal(t, "/hooks", cliPathFromFlags(cmd, "/hooks")) + }) + + t.Run("explicitly passing the default value still counts as set", func(t *testing.T) { + cmd := newCmd() + require.NoError(t, cmd.ParseFlags([]string{"--cli-path", "/"})) + assert.Equal(t, "/", cliPathFromFlags(cmd, "/")) + }) +} + +// TestCLIPathFromFlagsEndToEnd covers the combination that regressed: --config +// supplies a path, --cli-path is not passed, and the config value must survive. +func TestCLIPathFromFlagsEndToEnd(t *testing.T) { + // --cli-path is left unset, exactly as when the user passes only --config. + cmd := &cobra.Command{Use: "create"} + var cliPath string + cmd.Flags().StringVar(&cliPath, "cli-path", "/", "Path for CLI destinations") + require.NoError(t, cmd.ParseFlags([]string{})) + + config, err := buildDestinationConfigFromFlags(`{"path":"/from-config"}`, "", "CLI", nil) + require.NoError(t, err) + applyCLIPath(config, cliPathFromFlags(cmd, cliPath), true) + + assert.Equal(t, "/from-config", config["path"], + "an unset --cli-path must not overwrite the path from --config") +} + +// TestConnectionDestinationRejectsDeliveryPolicyForCLI covers the two +// connection paths, which build a delivery policy separately from the +// destination commands and so need their own guard. +func TestConnectionDestinationRejectsDeliveryPolicyForCLI(t *testing.T) { + t.Run("connection create", func(t *testing.T) { + cc := &connectionCreateCmd{} + cc.destinationType = "CLI" + cc.DestinationRateLimit = 100 + cc.DestinationRateLimitPeriod = "minute" + + _, err := cc.buildDestinationConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "--destination-rate-limit") + assert.Contains(t, err.Error(), "CLI destinations") + }) + + t.Run("connection upsert against an existing CLI destination", func(t *testing.T) { + cu := &connectionUpsertCmd{connectionCreateCmd: &connectionCreateCmd{}} + cu.DestinationRateLimit = 100 + cu.DestinationRateLimitPeriod = "minute" + + _, err := cu.buildDestinationInputForUpdate(&hookdeck.Destination{ + ID: "des_1", Name: "local", Type: "CLI", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "CLI destinations") + }) + + t.Run("connection upsert against an HTTP destination still applies", func(t *testing.T) { + cu := &connectionUpsertCmd{connectionCreateCmd: &connectionCreateCmd{}} + cu.DestinationRateLimit = 100 + cu.DestinationRateLimitPeriod = "minute" + + input, err := cu.buildDestinationInputForUpdate(&hookdeck.Destination{ + ID: "des_2", Name: "web", Type: "HTTP", + }) + require.NoError(t, err) + policy, ok := input.Config["delivery_policy"].(map[string]interface{}) + require.True(t, ok, "HTTP destinations must still receive the policy") + assert.Equal(t, 100, policy["rate"]) + }) +} diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index 4d3673e2..43aabc6c 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -134,6 +134,16 @@ func buildDeliveryPolicy(rate int, period, groupKey string, groupRate int, group return policy, nil } +// cliPathFromFlags returns the --cli-path value only when the user actually +// supplied it. The flag carries a "/" default on create, so comparing against "" +// would let an unset flag overwrite a path given via --config. +func cliPathFromFlags(cmd *cobra.Command, cliPath string) string { + if cmd != nil && !cmd.Flags().Changed("cli-path") { + return "" + } + return cliPath +} + // applyCLIPath sets the path for a CLI destination. An explicit --cli-path wins; // otherwise a path already supplied via --config is left alone. withDefault adds // the "/" default, which only create does — upsert leaves the field absent so the diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index 3a536346..f2d72490 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -121,13 +121,7 @@ func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args config["url"] = dc.url } if t == "CLI" { - // --cli-path defaults to "/", so compare against Changed rather than "": - // an unset flag must not overwrite a path supplied via --config. - cliPath := dc.cliPath - if !cmd.Flags().Changed("cli-path") { - cliPath = "" - } - applyCLIPath(config, cliPath, true) + applyCLIPath(config, cliPathFromFlags(cmd, dc.cliPath), true) } req := &hookdeck.DestinationCreateRequest{ diff --git a/pkg/cmd/metrics.go b/pkg/cmd/metrics.go index c99b71d8..1b2e5a39 100644 --- a/pkg/cmd/metrics.go +++ b/pkg/cmd/metrics.go @@ -97,6 +97,9 @@ func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags, filters ho cmd.Flags().StringVar(&f.output, "output", "", "Output format (json)") _ = cmd.MarkFlagRequired("start") _ = cmd.MarkFlagRequired("end") + // Every metrics endpoint rejects a request without measures, so catch it + // here rather than letting it become an API 422. MCP already enforces this. + _ = cmd.MarkFlagRequired("measures") } // rejectUnsupportedFilters names the flags the way the user typed them. diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go index 9d83a26e..b1a5c52f 100644 --- a/pkg/cmd/metrics_attempts.go +++ b/pkg/cmd/metrics_attempts.go @@ -24,7 +24,7 @@ func newMetricsAttemptsCmd() *metricsAttemptsCmd { Long: LongBeta(`Query metrics for delivery attempts (latency, success/failure). Measures: ` + metricsAttemptsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters, hookdeck.AttemptMetricsDimensions, hookdeck.EventStatusValues) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters, hookdeck.AttemptMetricsDimensions, hookdeck.AttemptStatusValues) return c } diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index 2b76ad52..32db14f9 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -10,7 +10,7 @@ import ( ) const metricsEventsMeasures = "count, successful_count, failed_count, scheduled_count, paused_count, error_rate, avg_attempts, scheduled_retry_count, pending, queue_depth, max_depth, max_age" -const metricsEventsDimensions = "connection_id, source_id, destination_id, delivery_group, issue_id" +const metricsEventsDimensions = hookdeck.EventMetricsDimensions type metricsEventsCmd struct { cmd *cobra.Command @@ -64,24 +64,6 @@ func hasDimension(params hookdeck.MetricsQueryParams, name string) bool { return false } -// translateQueueDepthMeasures maps the CLI's "queue_depth" onto the API's -// "max_depth", dropping a duplicate if both were requested. -func translateQueueDepthMeasures(measures []string) []string { - out := make([]string, 0, len(measures)) - seen := make(map[string]bool, len(measures)) - for _, m := range measures { - if m == "queue_depth" { - m = "max_depth" - } - if seen[m] { - continue - } - seen[m] = true - out = append(out, m) - } - return out -} - // queryEventMetricsConsolidated routes to the correct underlying API endpoint // based on the requested measures and dimensions. func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, params hookdeck.MetricsQueryParams) (hookdeck.MetricsResponse, error) { @@ -95,7 +77,7 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, // spelling for the route, advertised in --help, so translate it rather than // letting the API reject a measure we told the user to pass. queueParams := params - queueParams.Measures = translateQueueDepthMeasures(params.Measures) + queueParams.Measures = hookdeck.TranslateQueueDepthMeasures(params.Measures) return client.QueryQueueDepth(ctx, queueParams) } // 2. If measures include "pending" → QueryEventsPendingTimeseries. diff --git a/pkg/cmd/metrics_events_routing_test.go b/pkg/cmd/metrics_events_routing_test.go index 2df00af8..877364cd 100644 --- a/pkg/cmd/metrics_events_routing_test.go +++ b/pkg/cmd/metrics_events_routing_test.go @@ -1,22 +1,85 @@ package cmd import ( + "context" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) -// TestTranslateQueueDepthMeasures pins the mapping from the CLI's advertised -// "queue_depth" onto the measures the queue-depth endpoint actually accepts -// (max_depth, max_age). -func TestTranslateQueueDepthMeasures(t *testing.T) { - assert.Equal(t, []string{"max_depth"}, translateQueueDepthMeasures([]string{"queue_depth"})) - assert.Equal(t, []string{"max_depth"}, translateQueueDepthMeasures([]string{"max_depth"})) - assert.Equal(t, []string{"max_age"}, translateQueueDepthMeasures([]string{"max_age"})) +// routeCapture records the path and query of the request the routing actually +// produced, so these tests pin the wiring rather than a pure helper. +func routeCapture(t *testing.T) (*hookdeck.Client, *string, *url.Values) { + t.Helper() + var gotPath string + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + baseURL, err := url.Parse(srv.URL) + require.NoError(t, err) + return &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}, &gotPath, &gotQuery +} + +// TestPendingRoutesWithoutGranularity pins the routing fix: "pending" selects +// the pending-timeseries endpoint on the measure alone. Granularity is optional +// on that route, and gating on it sent the request to the default events +// endpoint, which does not define the measure. +func TestPendingRoutesWithoutGranularity(t *testing.T) { + client, path, query := routeCapture(t) + + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{"pending"}}) + require.NoError(t, err) + + assert.Contains(t, *path, "events-pending-timeseries", + "pending must not fall through to the default events route") + // "pending" only selects the route; the API expects measures[]=count. + assert.Equal(t, []string{"count"}, (*query)["measures[]"]) +} - // Both spellings collapse to one measure rather than sending it twice. - assert.Equal(t, []string{"max_depth"}, translateQueueDepthMeasures([]string{"queue_depth", "max_depth"})) - assert.Equal(t, []string{"max_depth", "max_age"}, translateQueueDepthMeasures([]string{"queue_depth", "max_age"})) +// TestPendingStillRoutesWithGranularity guards the case that already worked. +func TestPendingStillRoutesWithGranularity(t *testing.T) { + client, path, _ := routeCapture(t) - assert.Empty(t, translateQueueDepthMeasures(nil)) + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{"pending"}, Granularity: "1h"}) + require.NoError(t, err) + assert.Contains(t, *path, "events-pending-timeseries") +} + +// TestQueueDepthMeasureIsTranslatedOnTheWire pins the translation wiring, not +// just the helper: the endpoint accepts max_depth and max_age only, so sending +// our own "queue_depth" spelling is rejected by the API. +func TestQueueDepthMeasureIsTranslatedOnTheWire(t *testing.T) { + client, path, query := routeCapture(t) + + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{"queue_depth"}}) + require.NoError(t, err) + + assert.Contains(t, *path, "queue-depth") + assert.Equal(t, []string{"max_depth"}, (*query)["measures[]"], + "queue_depth must reach the API as max_depth") +} + +// TestTranslateQueueDepthMeasures covers the helper's edge cases. +func TestTranslateQueueDepthMeasures(t *testing.T) { + assert.Equal(t, []string{"max_depth"}, hookdeck.TranslateQueueDepthMeasures([]string{"queue_depth"})) + assert.Equal(t, []string{"max_age"}, hookdeck.TranslateQueueDepthMeasures([]string{"max_age"})) + // Both spellings collapse to one measure rather than being sent twice. + assert.Equal(t, []string{"max_depth"}, hookdeck.TranslateQueueDepthMeasures([]string{"queue_depth", "max_depth"})) + assert.Equal(t, []string{"max_depth", "max_age"}, hookdeck.TranslateQueueDepthMeasures([]string{"queue_depth", "max_age"})) + assert.Empty(t, hookdeck.TranslateQueueDepthMeasures(nil)) } diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go index c63a2258..d83abb3e 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -24,7 +24,7 @@ func newMetricsTransformationsCmd() *metricsTransformationsCmd { Long: LongBeta(`Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters, hookdeck.TransformationMetricsDimensions, hookdeck.EventStatusValues) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters, hookdeck.TransformationMetricsDimensions, "") return c } diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index 6f0b87d8..16a5219c 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -111,8 +111,12 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp if err := rejectFilters(params, hookdeck.QueueDepthRouteFilters, "queue depth metrics"); err != nil { return ErrorResult(err.Error()), nil } - result, err = client.QueryQueueDepth(ctx, params) - case containsAny(params.Measures, "pending") && params.Granularity != "": + // The endpoint accepts max_depth and max_age only; "queue_depth" is our + // own spelling for the route, so translate it as the CLI does. + queueParams := params + queueParams.Measures = hookdeck.TranslateQueueDepthMeasures(params.Measures) + result, err = client.QueryQueueDepth(ctx, queueParams) + case containsAny(params.Measures, "pending"): if err := rejectFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (measures: pending)"); err != nil { return ErrorResult(err.Error()), nil } diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index d783b5b8..539ccffd 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -100,5 +100,25 @@ const ( // events and attempts carry a delivery status. const ( RequestStatusValues = "ACCEPTED, REJECTED" - EventStatusValues = "SUCCESSFUL, FAILED, QUEUED, PAUSED" + EventStatusValues = "SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED" + AttemptStatusValues = "SUCCESSFUL, FAILED" ) + +// TranslateQueueDepthMeasures maps the CLI's and MCP's "queue_depth" spelling +// onto the API's "max_depth", dropping a duplicate if both were requested. The +// queue-depth endpoint accepts max_depth and max_age only. +func TranslateQueueDepthMeasures(measures []string) []string { + out := make([]string, 0, len(measures)) + seen := make(map[string]bool, len(measures)) + for _, m := range measures { + if m == "queue_depth" { + m = "max_depth" + } + if seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + return out +} From f05ca9e22a460d1ce9aed4f31709a4e2d08d5065 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 17:47:33 +0100 Subject: [PATCH 04/16] fix: stop upsert destroying delivery group overrides Closes #393. Bumping a delivery group's rate wiped the per-group overrides, silently. The API merges delivery_policy one level deep but replaces groups wholesale, so a groups object sent without overrides takes the stored ones with it. The CLI requires --delivery-group-key and --delivery-group-rate-period whenever --delivery-group-rate is given, so "just change the rate" always sends a full groups object -- the one command a user would reach for was the one that lost data, with no warning and nothing in --dry-run to reveal it. Carry the stored overrides forward when the caller did not supply their own. An explicit --delivery-group-overrides still wins, and '{}' still clears, so deliberately emptying them is unaffected. Three paths needed it: destination upsert, which now fetches the existing destination when a groups object would otherwise go out bare; and both connection upsert paths, which already hold the existing destination and so cost no extra request. This is a read-modify-write and races a concurrent edit of the same destination, which is the same exposure upsert already had for every other field it preserves. Verified against the live API: created a destination carrying overrides, bumped the group rate, and confirmed the overrides survived and the rate changed. The pre-fix binary run against the same destination drops them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/connection_upsert.go | 7 ++ pkg/cmd/delivery_group_overrides_test.go | 124 +++++++++++++++++++++++ pkg/cmd/destination_common.go | 50 +++++++++ pkg/cmd/destination_upsert.go | 15 ++- 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 pkg/cmd/delivery_group_overrides_test.go diff --git a/pkg/cmd/connection_upsert.go b/pkg/cmd/connection_upsert.go index d1f7aabd..6cfac764 100644 --- a/pkg/cmd/connection_upsert.go +++ b/pkg/cmd/connection_upsert.go @@ -481,6 +481,12 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection, if err != nil { return nil, err } + // This builder is the create-shaped one, but --destination-name against an + // existing connection updates that destination, so the stored overrides + // have to survive here too. + if isUpdate && existing != nil && existing.Destination != nil { + preserveDeliveryGroupOverrides(destinationInput.Config, existing.Destination.Config) + } req.Destination = destinationInput } else if isUpdate && existing != nil && existing.Destination != nil { // Check if any destination config fields are being updated @@ -620,6 +626,7 @@ func (cu *connectionUpsertCmd) buildDestinationInputForUpdate(existingDest *hook return nil, err } mergeDeliveryPolicy(destConfig, policy) + preserveDeliveryGroupOverrides(destConfig, existingDest.Config) // Apply authentication config if provided if cu.DestinationAuthMethod != "" { diff --git a/pkg/cmd/delivery_group_overrides_test.go b/pkg/cmd/delivery_group_overrides_test.go new file mode 100644 index 00000000..c15f57eb --- /dev/null +++ b/pkg/cmd/delivery_group_overrides_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +func groupsConfig(groups map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "delivery_policy": map[string]interface{}{"groups": groups}, + } +} + +func storedOverrides() map[string]interface{} { + return map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": 5, "rate_period": "minute"}, + } +} + +// TestPreserveDeliveryGroupOverrides pins the fix for the data loss in #393: +// the API replaces delivery_policy.groups wholesale, so an upsert that sends a +// groups object without overrides destroys the stored ones. +func TestPreserveDeliveryGroupOverrides(t *testing.T) { + t.Run("stored overrides survive a rate bump", func(t *testing.T) { + config := groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + }) + existing := groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": storedOverrides(), + }) + + preserveDeliveryGroupOverrides(config, existing) + + groups, ok := nestedMap(config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverrides(), groups["overrides"], + "bumping the rate must not destroy per-group overrides") + assert.Equal(t, 30, groups["rate"], "the requested change must still apply") + }) + + t.Run("explicit overrides win over stored ones", func(t *testing.T) { + mine := map[string]interface{}{"cust_2": map[string]interface{}{"rate": 99}} + config := groupsConfig(map[string]interface{}{"key": "k", "overrides": mine}) + preserveDeliveryGroupOverrides(config, groupsConfig(map[string]interface{}{ + "key": "k", "overrides": storedOverrides(), + })) + + groups, _ := nestedMap(config, "delivery_policy", "groups") + assert.Equal(t, mine, groups["overrides"]) + }) + + t.Run("explicitly clearing overrides is honoured", func(t *testing.T) { + empty := map[string]interface{}{} + config := groupsConfig(map[string]interface{}{"key": "k", "overrides": empty}) + preserveDeliveryGroupOverrides(config, groupsConfig(map[string]interface{}{ + "key": "k", "overrides": storedOverrides(), + })) + + groups, _ := nestedMap(config, "delivery_policy", "groups") + assert.Equal(t, empty, groups["overrides"], + "--delivery-group-overrides '{}' must clear, not be silently refilled") + }) + + t.Run("no stored overrides is a no-op", func(t *testing.T) { + config := groupsConfig(map[string]interface{}{"key": "k", "rate": 30}) + preserveDeliveryGroupOverrides(config, groupsConfig(map[string]interface{}{"key": "k"})) + + groups, _ := nestedMap(config, "delivery_policy", "groups") + _, has := groups["overrides"] + assert.False(t, has) + }) + + t.Run("survives absent and malformed configs", func(t *testing.T) { + assert.NotPanics(t, func() { + preserveDeliveryGroupOverrides(nil, nil) + preserveDeliveryGroupOverrides(map[string]interface{}{}, nil) + preserveDeliveryGroupOverrides( + groupsConfig(map[string]interface{}{"key": "k"}), + map[string]interface{}{"delivery_policy": "not-a-map"}, + ) + }) + }) +} + +func TestDeliveryGroupsNeedOverrides(t *testing.T) { + assert.True(t, deliveryGroupsNeedOverrides(groupsConfig(map[string]interface{}{"key": "k"}))) + assert.False(t, deliveryGroupsNeedOverrides(groupsConfig(map[string]interface{}{ + "key": "k", "overrides": map[string]interface{}{}, + }))) + // No groups object at all: a destination-level rate limit is merged safely + // by the API, so nothing needs carrying forward. + assert.False(t, deliveryGroupsNeedOverrides(map[string]interface{}{ + "delivery_policy": map[string]interface{}{"rate": 100, "period": "minute"}, + })) + assert.False(t, deliveryGroupsNeedOverrides(nil)) +} + +// TestConnectionUpsertPreservesOverrides covers the path that already holds the +// existing destination, so no extra request is needed. +func TestConnectionUpsertPreservesOverrides(t *testing.T) { + cu := &connectionUpsertCmd{connectionCreateCmd: &connectionCreateCmd{}} + cu.DestinationDeliveryGroupKey = "body.customer_id" + cu.DestinationDeliveryGroupRate = 30 + cu.DestinationDeliveryGroupRatePeriod = "second" + + input, err := cu.buildDestinationInputForUpdate(&hookdeck.Destination{ + ID: "des_1", Name: "web", Type: "HTTP", + Config: groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": storedOverrides(), + }), + }) + require.NoError(t, err) + + groups, ok := nestedMap(input.Config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverrides(), groups["overrides"]) + assert.Equal(t, 30, groups["rate"]) +} diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index 43aabc6c..e6690a0b 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -161,6 +161,56 @@ func applyCLIPath(config map[string]interface{}, cliPath string, withDefault boo } } +// nestedMap walks a chain of map keys, returning false if any level is missing +// or is not itself a map. +func nestedMap(m map[string]interface{}, keys ...string) (map[string]interface{}, bool) { + cur := m + for _, k := range keys { + if cur == nil { + return nil, false + } + next, ok := cur[k].(map[string]interface{}) + if !ok { + return nil, false + } + cur = next + } + return cur, true +} + +// deliveryGroupsNeedOverrides reports whether config sets delivery_policy.groups +// without supplying overrides, which is the case that would destroy stored ones. +func deliveryGroupsNeedOverrides(config map[string]interface{}) bool { + groups, ok := nestedMap(config, "delivery_policy", "groups") + if !ok { + return false + } + _, given := groups["overrides"] + return !given +} + +// preserveDeliveryGroupOverrides carries delivery_policy.groups.overrides +// forward from the stored config when the caller did not supply its own. +// +// The API merges delivery_policy one level deep but replaces groups wholesale, +// so sending a groups object without overrides silently destroys them. Since +// the CLI requires --delivery-group-key and --delivery-group-rate-period +// whenever --delivery-group-rate is given, "just bump the rate" always sends a +// full groups object, and was always the command that lost the overrides. +func preserveDeliveryGroupOverrides(config, existingConfig map[string]interface{}) { + if !deliveryGroupsNeedOverrides(config) { + return + } + existing, ok := nestedMap(existingConfig, "delivery_policy", "groups") + if !ok { + return + } + if overrides, ok := existing["overrides"]; ok { + groups, _ := nestedMap(config, "delivery_policy", "groups") + groups["overrides"] = overrides + } +} + // rejectDeliveryPolicyForCLI refuses delivery-policy flags on a CLI destination. // CLI destinations carry no delivery_policy in the API schema: the request is // accepted and the policy discarded, so without this the flags look applied and diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index bd1f4275..ef774aaa 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -129,15 +129,22 @@ func (dc *destinationUpsertCmd) runDestinationUpsertCmd(cmd *cobra.Command, args } // API requires config on PUT. When doing partial update (e.g. only --description), fetch existing and merge. - if req.Config == nil || len(req.Config) == 0 { + // A groups object sent without overrides also needs the stored config, because + // the API replaces groups wholesale and would drop the overrides with it. + needsOverrides := deliveryGroupsNeedOverrides(req.Config) + if req.Config == nil || len(req.Config) == 0 || needsOverrides { params := map[string]string{"name": dc.name} listResp, err := client.ListDestinations(ctx, params) if err == nil && listResp.Models != nil && len(listResp.Models) > 0 { existing, err := client.GetDestination(ctx, listResp.Models[0].ID, nil) if err == nil && existing.Config != nil { - req.Config = existing.Config - if req.Type == "" { - req.Type = existing.Type + if len(req.Config) == 0 { + req.Config = existing.Config + if req.Type == "" { + req.Type = existing.Type + } + } else { + preserveDeliveryGroupOverrides(req.Config, existing.Config) } } } From fbb924ab16cc39f1ea9e5fd916c7270629f847d3 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 18:51:37 +0100 Subject: [PATCH 05/16] fix: act on the Copilot review, and add the missing acceptance coverage Five pkg/ defects and the acceptance gap that let them through. Copilot's four findings, plus a fifth the new acceptance tests caught: 1. Mixed measures across routes silently dropped data. Routing on "pending" alone also captured --measures pending,failed_count, and the pending branch replaces the whole measure list with count -- exit 0, failed_count gone. RejectMixedMeasureRoutes now refuses the combination, shared by the CLI and MCP so the two cannot drift. 2. The CLI delivery-policy guard missed the common form. update and upsert normally omit --type, so destType was "" and the guard returned nil while the API silently discarded the policy. The stored type is now resolved, with the lookup skipped whenever it cannot change the outcome. 3. Overrides preservation failed open. A transient lookup error left a bare groups object going out, reintroducing the #393 data loss through the error path. It now refuses rather than proceeding. 4. The CLI-path fix did not fire for --destination-name plus --destination-type CLI, the ordinary idempotent form, because the existence lookup was skipped. Suppressing the default alone would have sent path:"" instead, trading one silent clobber for another, so the unconditional assignment went too. 5. destination update wiped delivery group overrides -- the same #393 loss on a third command, which neither the unit tests nor two code reviews caught. It reuses the lookup the type resolution already performs. Acceptance coverage: delivery groups had none at all, on the feature this release exists for. 13 test functions and 28 subtests now cover the standalone and inline forms, CLI-type rejection, partial and invalid flag sets, and -- the case that matters -- that bumping a group rate preserves the stored overrides on update, upsert and connection upsert. Defect 5 was found by writing them. Also adds the two metrics cases whose absence let real bugs ship: --measures pending with no --granularity, and --measures queue_depth. The existing tests were written around the broken behaviour, passing --granularity 1h and using max_depth, so they could never have failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/connection_create.go | 8 +- pkg/cmd/connection_upsert.go | 55 +- pkg/cmd/connection_upsert_test.go | 191 ++++++ pkg/cmd/destination_common.go | 125 +++- pkg/cmd/destination_stored_state_test.go | 565 ++++++++++++++++++ pkg/cmd/destination_update.go | 91 ++- pkg/cmd/destination_upsert.go | 132 ++-- pkg/cmd/metrics_events.go | 7 + pkg/cmd/metrics_events_routing_test.go | 73 +++ pkg/gateway/mcp/tool_metrics.go | 6 + pkg/gateway/mcp/tool_metrics_measures_test.go | 83 +++ pkg/hookdeck/metrics_filters.go | 60 ++ .../connection_delivery_group_test.go | 325 ++++++++++ .../destination_delivery_group_test.go | 457 ++++++++++++++ test/acceptance/metrics_test.go | 30 + 15 files changed, 2123 insertions(+), 85 deletions(-) create mode 100644 pkg/cmd/destination_stored_state_test.go create mode 100644 pkg/gateway/mcp/tool_metrics_measures_test.go create mode 100644 test/acceptance/connection_delivery_group_test.go create mode 100644 test/acceptance/destination_delivery_group_test.go diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index ae61b43e..d285db2a 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -577,7 +577,13 @@ func (cc *connectionCreateCmd) buildDestinationInput() (*hookdeck.DestinationCre destinationConfig["http_method"] = method } case "CLI": - destinationConfig["path"] = cc.destinationCliPath + // An empty path means "leave it alone". connection create never reaches + // that (its --destination-cli-path defaults to "/"), but connection + // upsert deliberately clears it against an existing CLI destination, and + // sending "" would reset the stored path just as "/" did. + if cc.destinationCliPath != "" { + destinationConfig["path"] = cc.destinationCliPath + } case "MOCK_API": // No extra fields needed for MOCK_API default: diff --git a/pkg/cmd/connection_upsert.go b/pkg/cmd/connection_upsert.go index 6cfac764..54f559bf 100644 --- a/pkg/cmd/connection_upsert.go +++ b/pkg/cmd/connection_upsert.go @@ -292,18 +292,16 @@ func (cu *connectionUpsertCmd) validateDestinationFlags() error { return nil } -func (cu *connectionUpsertCmd) runConnectionUpsertCmd(cmd *cobra.Command, args []string) error { - // Get name from positional argument - name := args[0] - cu.name = name - - client := Config.GetAPIClient() - - // Determine if we need to fetch existing connection - // Only needed when: - // 1. Dry-run mode (to show preview) - // 2. Partial update (source/destination config fields without name/type) - // 3. Updating config fields without recreating the resource +// needsExistingConnection reports whether the upsert has to look the connection +// up before building the request. The lookup is skipped where it cannot change +// the outcome, because upsert is otherwise a single API call. +// +// It is needed when: +// 1. Dry-run mode (to show the preview) +// 2. Partial update (source/destination config fields without name/type) +// 3. A name is given without a type, which is filled in from the stored record +// 4. A create-time default would otherwise be applied to a stored destination +func (cu *connectionUpsertCmd) needsExistingConnection() bool { hasSourceConfigOnly := (cu.SourceWebhookSecret != "" || cu.SourceAPIKey != "" || cu.SourceBasicAuthUser != "" || cu.SourceBasicAuthPass != "" || cu.SourceHMACSecret != "" || cu.SourceHMACAlgo != "" || @@ -322,12 +320,41 @@ func (cu *connectionUpsertCmd) runConnectionUpsertCmd(cmd *cobra.Command, args [ hasPartialSourceInline := (cu.sourceName != "" && cu.sourceType == "" && cu.sourceID == "") hasPartialDestinationInline := (cu.destinationName != "" && cu.destinationType == "" && cu.destinationID == "") - needsExisting := cu.dryRun || (!cu.hasAnySourceFlag() && !cu.hasAnyDestinationFlag()) || hasSourceConfigOnly || hasDestinationConfigOnly || hasPartialSourceInline || hasPartialDestinationInline + // The ordinary idempotent form supplies --destination-name and + // --destination-type together, which none of the conditions above catch. + // Two create-time behaviours are wrong against a connection that already + // exists, and both need to know whether it does: + // - a CLI destination with no --destination-cli-path gets the "/" default, + // resetting a stored custom path; + // - delivery-group flags without --destination-delivery-group-overrides + // build a bare groups object, which replaces the stored one and takes + // the overrides with it (#393). + inlineDestination := cu.destinationID == "" && (cu.destinationName != "" || cu.destinationType != "") + cliPathDefaultWouldApply := inlineDestination && + strings.EqualFold(cu.destinationType, "CLI") && cu.destinationCliPath == "" + groupsWouldDropOverrides := inlineDestination && + cu.DestinationDeliveryGroupOverrides == "" && + (cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 || + cu.DestinationDeliveryGroupRatePeriod != "") + + return cu.dryRun || + (!cu.hasAnySourceFlag() && !cu.hasAnyDestinationFlag()) || + hasSourceConfigOnly || hasDestinationConfigOnly || + hasPartialSourceInline || hasPartialDestinationInline || + cliPathDefaultWouldApply || groupsWouldDropOverrides +} + +func (cu *connectionUpsertCmd) runConnectionUpsertCmd(cmd *cobra.Command, args []string) error { + // Get name from positional argument + name := args[0] + cu.name = name + + client := Config.GetAPIClient() var existing *hookdeck.Connection var isUpdate bool - if needsExisting { + if cu.needsExistingConnection() { connections, err := client.ListConnections(context.Background(), map[string]string{ "name": name, }) diff --git a/pkg/cmd/connection_upsert_test.go b/pkg/cmd/connection_upsert_test.go index 4e5ab0b6..8719558c 100644 --- a/pkg/cmd/connection_upsert_test.go +++ b/pkg/cmd/connection_upsert_test.go @@ -520,3 +520,194 @@ func TestUpsertBuildRequestFillsSourceTypeFromExisting(t *testing.T) { assert.Equal(t, "new-source-name", req.Source.Name) assert.Equal(t, "WEBHOOK", req.Source.Type, "Should fill type from existing source") } + +// newUpsertCmdForFlags builds an upsert command struct directly, which is how +// the flag-driven decisions below are reachable without a live API client. +func newUpsertCmdForFlags() *connectionUpsertCmd { + return &connectionUpsertCmd{connectionCreateCmd: &connectionCreateCmd{}} +} + +// TestNeedsExistingConnection pins the lookup decision. The create-time +// behaviours below are wrong against a connection that already exists, and the +// ordinary idempotent invocation - --destination-name plus --destination-type +// together - matched none of the original conditions, so the guards that depend +// on knowing the connection exists never fired. +func TestNeedsExistingConnection(t *testing.T) { + t.Run("CLI destination by name and type must still look the connection up", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationName = "local" + cu.destinationType = "CLI" + + assert.True(t, cu.needsExistingConnection(), + `without the lookup the "/" default resets a stored custom path`) + }) + + t.Run("lowercase --destination-type cli counts too", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationName = "local" + cu.destinationType = "cli" + + assert.True(t, cu.needsExistingConnection()) + }) + + t.Run("an explicit --destination-cli-path needs no lookup", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationName = "local" + cu.destinationType = "CLI" + cu.destinationCliPath = "/webhooks" + + assert.False(t, cu.needsExistingConnection(), + "the caller said what the path should be, so nothing is being defaulted") + }) + + t.Run("delivery group flags without overrides must look the connection up", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationName = "web" + cu.destinationType = "HTTP" + cu.destinationURL = "https://api.example.com" + cu.DestinationDeliveryGroupKey = "body.customer_id" + cu.DestinationDeliveryGroupRate = 30 + cu.DestinationDeliveryGroupRatePeriod = "second" + + assert.True(t, cu.needsExistingConnection(), + "without the lookup the bare groups object replaces the stored overrides (#393)") + }) + + t.Run("delivery group flags with overrides need no lookup", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationName = "web" + cu.destinationType = "HTTP" + cu.destinationURL = "https://api.example.com" + cu.DestinationDeliveryGroupKey = "body.customer_id" + cu.DestinationDeliveryGroupRate = 30 + cu.DestinationDeliveryGroupRatePeriod = "second" + cu.DestinationDeliveryGroupOverrides = `{"cust_1":{"rate":5,"rate_period":"minute"}}` + + assert.False(t, cu.needsExistingConnection(), + "the caller supplied the overrides, so there is nothing to carry forward") + }) + + t.Run("an HTTP destination by name and type still needs no lookup", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationName = "web" + cu.destinationType = "HTTP" + cu.destinationURL = "https://api.example.com" + + assert.False(t, cu.needsExistingConnection(), + "upsert is meant to be one API call where the lookup cannot change the outcome") + }) + + t.Run("--destination-id is the caller pointing at a destination, not creating one", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.destinationID = "des_1" + + assert.False(t, cu.needsExistingConnection()) + }) + + t.Run("the pre-existing conditions still hold", func(t *testing.T) { + dryRun := newUpsertCmdForFlags() + dryRun.dryRun = true + dryRun.destinationName = "web" + dryRun.destinationType = "HTTP" + assert.True(t, dryRun.needsExistingConnection()) + + nameOnly := newUpsertCmdForFlags() + nameOnly.destinationName = "web" + assert.True(t, nameOnly.needsExistingConnection(), "a name without a type is filled in from the stored record") + + noFlags := newUpsertCmdForFlags() + assert.True(t, noFlags.needsExistingConnection()) + }) +} + +// TestUpsertKeepsStoredCLIPath pins the other half of the same fix: once the +// connection is known to exist, the request must leave the path alone rather +// than send "/" or "". +func TestUpsertKeepsStoredCLIPath(t *testing.T) { + existing := &hookdeck.Connection{ + ID: "web_1", + Name: strPtr("my-connection"), + Destination: &hookdeck.Destination{ + ID: "des_1", Name: "local", Type: "CLI", + Config: map[string]interface{}{"path": "/webhooks"}, + }, + } + + t.Run("no path is sent when the flag is omitted", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.name = "my-connection" + cu.destinationName = "local" + cu.destinationType = "CLI" + + req, err := cu.buildUpsertRequest(existing, true) + require.NoError(t, err) + require.NotNil(t, req.Destination) + _, sent := req.Destination.Config["path"] + assert.False(t, sent, "sending either / or \"\" overwrites the stored path") + }) + + t.Run("an explicit path is still sent", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.name = "my-connection" + cu.destinationName = "local" + cu.destinationType = "CLI" + cu.destinationCliPath = "/other" + + req, err := cu.buildUpsertRequest(existing, true) + require.NoError(t, err) + assert.Equal(t, "/other", req.Destination.Config["path"]) + }) + + t.Run("a create still gets the / default", func(t *testing.T) { + cu := newUpsertCmdForFlags() + cu.name = "brand-new" + cu.destinationName = "local" + cu.destinationType = "CLI" + + req, err := cu.buildUpsertRequest(nil, false) + require.NoError(t, err) + assert.Equal(t, "/", req.Destination.Config["path"]) + }) +} + +// TestUpsertPreservesOverridesOnInlineDestination covers the second guard at the +// same site: --destination-name with a delivery-group bump must carry the stored +// overrides forward. +func TestUpsertPreservesOverridesOnInlineDestination(t *testing.T) { + existing := &hookdeck.Connection{ + ID: "web_1", + Name: strPtr("my-connection"), + Destination: &hookdeck.Destination{ + ID: "des_2", Name: "web", Type: "HTTP", + Config: map[string]interface{}{ + "url": "https://api.example.com", + "delivery_policy": map[string]interface{}{ + "groups": map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": 5, "rate_period": "minute"}, + }, + }, + }, + }, + }, + } + + cu := newUpsertCmdForFlags() + cu.name = "my-connection" + cu.destinationName = "web" + cu.destinationType = "HTTP" + cu.destinationURL = "https://api.example.com" + cu.DestinationDeliveryGroupKey = "body.customer_id" + cu.DestinationDeliveryGroupRate = 30 + cu.DestinationDeliveryGroupRatePeriod = "second" + + req, err := cu.buildUpsertRequest(existing, true) + require.NoError(t, err) + require.NotNil(t, req.Destination) + + groups, ok := nestedMap(req.Destination.Config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, 30, groups["rate"]) + assert.NotNil(t, groups["overrides"], "the stored overrides must survive a rate bump") +} diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index e6690a0b..6ea09a98 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -1,12 +1,15 @@ package cmd import ( + "context" "encoding/json" "fmt" "os" "strings" "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) // destinationConfigFlags holds destination config flags for create/upsert/update. @@ -211,10 +214,119 @@ func preserveDeliveryGroupOverrides(config, existingConfig map[string]interface{ } } +// applyStoredDestinationConfig fills in what an upsert PUT needs from the stored +// destination. Two distinct cases share the lookup: +// +// - a partial update (only --description, say) sends no config at all, and the +// API requires one on PUT, so the stored config is carried forward; +// - a delivery_policy.groups object sent without overrides would replace the +// stored groups wholesale and take the overrides with it (#393), so the +// stored overrides are carried forward. +// +// The two differ in how a failed lookup has to be treated. The first can carry +// on: the worst outcome is the API rejecting a config-less PUT, which is visible. +// The second cannot: continuing sends the bare groups object and destroys the +// overrides this is here to protect, and the PUT succeeds, so nothing reports it. +func applyStoredDestinationConfig(name string, req *hookdeck.DestinationCreateRequest, lookup func() (*hookdeck.Destination, error)) error { + if len(req.Config) > 0 { + return preserveStoredDeliveryGroupOverrides(name, req.Config, lookup) + } + + // Partial update: the API requires a config on PUT, so the stored one is + // carried forward. A failed lookup is tolerable here — the worst outcome is + // the API rejecting a config-less PUT, which the user sees. + existing, err := lookup() + if err != nil || existing == nil || existing.Config == nil { + return nil + } + req.Config = existing.Config + if req.Type == "" { + req.Type = existing.Type + } + return nil +} + +// preserveStoredDeliveryGroupOverrides carries the stored +// delivery_policy.groups.overrides into config when config sets groups without +// them, and refuses to proceed if it cannot read them. +// +// The API replaces groups wholesale, so sending a bare groups object destroys +// the stored overrides (#393) — and the PUT succeeds, so nothing reports it. +// That makes a failed lookup unrecoverable: carrying on would do the exact +// damage this is here to prevent. Every command that can send a groups object +// goes through here, so update, upsert and connection upsert cannot drift. +func preserveStoredDeliveryGroupOverrides(name string, config map[string]interface{}, lookup func() (*hookdeck.Destination, error)) error { + if !deliveryGroupsNeedOverrides(config) { + return nil + } + existing, err := lookup() + if err != nil { + return fmt.Errorf("failed to look up destination %q to preserve its delivery group overrides; refusing to send a delivery group that would replace them: %w", name, err) + } + if existing == nil || existing.Config == nil { + return nil + } + preserveDeliveryGroupOverrides(config, existing.Config) + return nil +} + +// hasAnyDeliveryPolicyFlag reports whether a rate-limit or delivery-group flag +// was given. It decides whether resolving the stored destination type is worth +// an extra API call. +func (f *destinationConfigFlags) hasAnyDeliveryPolicyFlag() bool { + if f == nil { + return false + } + return f.RateLimit != 0 || f.RateLimitPeriod != "" || f.DeliveryGroupKey != "" || + f.DeliveryGroupRate != 0 || f.DeliveryGroupRatePeriod != "" || f.DeliveryGroupOverrides != "" +} + +// destinationTypeForPolicyCheck resolves the type that rejectDeliveryPolicyForCLI +// has to be applied to. +// +// `destination update` and `destination upsert` normally omit --type, so the +// flag alone is "" and the guard passed: rate-limit and delivery-group flags +// then reached a stored CLI destination, where the API accepts the request and +// discards the policy. Resolving the stored type is preferred over refusing the +// command, because the type the user omitted is already knowable and every one +// of these commands is addressing a destination the API can name. +// +// lookup returns the stored destination, or (nil, nil) when there is none — +// an upsert that is really a create, where a typeless request is the API's to +// reject. It is only called when the answer can change the outcome. +func destinationTypeForPolicyCheck(declaredType string, usesConfigJSON bool, flags *destinationConfigFlags, lookup func() (*hookdeck.Destination, error)) (string, error) { + if declaredType != "" || usesConfigJSON || !flags.hasAnyDeliveryPolicyFlag() { + return declaredType, nil + } + existing, err := lookup() + if err != nil { + return "", fmt.Errorf("failed to look up the destination to check whether --rate-limit and --delivery-group-* apply to it: %w", err) + } + if existing == nil { + return declaredType, nil + } + return existing.Type, nil +} + +// fetchDestinationByName returns the stored destination with this exact name, or +// (nil, nil) when none exists. The list endpoint filters by name, but returns a +// summary, so the full record is fetched for its config. +func fetchDestinationByName(ctx context.Context, client *hookdeck.Client, name string) (*hookdeck.Destination, error) { + listResp, err := client.ListDestinations(ctx, map[string]string{"name": name}) + if err != nil { + return nil, err + } + if listResp == nil || len(listResp.Models) == 0 { + return nil, nil + } + return client.GetDestination(ctx, listResp.Models[0].ID, nil) +} + // rejectDeliveryPolicyForCLI refuses delivery-policy flags on a CLI destination. // CLI destinations carry no delivery_policy in the API schema: the request is // accepted and the policy discarded, so without this the flags look applied and -// never take effect. +// never take effect. destType must be the resolved type, not the raw --type flag +// — see destinationTypeForPolicyCheck. func rejectDeliveryPolicyForCLI(destType string, policy map[string]interface{}, flagPrefix string) error { if len(policy) == 0 || strings.ToUpper(destType) != "CLI" { return nil @@ -222,6 +334,17 @@ func rejectDeliveryPolicyForCLI(destType string, policy map[string]interface{}, return fmt.Errorf("--%srate-limit and --%sdelivery-group-* are not supported for CLI destinations", flagPrefix, flagPrefix) } +// rejectDeliveryPolicyInConfigForCLI applies the same guard to an already-built +// config. update and upsert build the config before the stored type is known, +// and the type is what decides whether the policy survives the API. +func rejectDeliveryPolicyInConfigForCLI(destType string, config map[string]interface{}, flagPrefix string) error { + policy, ok := config["delivery_policy"].(map[string]interface{}) + if !ok { + return nil + } + return rejectDeliveryPolicyForCLI(destType, policy, flagPrefix) +} + func mergeDeliveryPolicy(config map[string]interface{}, policy map[string]interface{}) { if len(policy) == 0 { return diff --git a/pkg/cmd/destination_stored_state_test.go b/pkg/cmd/destination_stored_state_test.go new file mode 100644 index 00000000..709dacb5 --- /dev/null +++ b/pkg/cmd/destination_stored_state_test.go @@ -0,0 +1,565 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// destinationAPI serves the list-by-name and get-by-id pair that upsert uses to +// find the stored destination, so these tests pin the wiring and not just a +// helper. status is applied to every response; 0 means 200. +func destinationAPI(t *testing.T, stored *hookdeck.Destination, status int) *hookdeck.Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status != 0 { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == hookdeck.APIPathPrefix+"/destinations" { + models := []hookdeck.Destination{} + if stored != nil { + models = append(models, *stored) + } + _ = json.NewEncoder(w).Encode(hookdeck.DestinationListResponse{Models: models}) + return + } + _ = json.NewEncoder(w).Encode(stored) + })) + t.Cleanup(srv.Close) + + baseURL, err := url.Parse(srv.URL) + require.NoError(t, err) + return &hookdeck.Client{BaseURL: baseURL, APIKey: "k"} +} + +// TestDestinationTypeForPolicyCheck pins the guard that `destination update` and +// `destination upsert` were missing: both normally omit --type, so comparing +// rate-limit and delivery-group flags against the flag alone always passed and +// the flags reached a stored CLI destination, where the API discards them. +func TestDestinationTypeForPolicyCheck(t *testing.T) { + cli := &hookdeck.Destination{ID: "des_1", Name: "local", Type: "CLI"} + policyFlags := &destinationConfigFlags{RateLimit: 100, RateLimitPeriod: "minute"} + lookup := func() (*hookdeck.Destination, error) { return cli, nil } + + t.Run("omitted --type resolves to the stored type", func(t *testing.T) { + got, err := destinationTypeForPolicyCheck("", false, policyFlags, lookup) + require.NoError(t, err) + assert.Equal(t, "CLI", got) + }) + + t.Run("an explicit --type is trusted and no lookup happens", func(t *testing.T) { + got, err := destinationTypeForPolicyCheck("HTTP", false, policyFlags, func() (*hookdeck.Destination, error) { + t.Fatal("must not spend an API call when --type was given") + return nil, nil + }) + require.NoError(t, err) + assert.Equal(t, "HTTP", got) + }) + + t.Run("no policy flags means no lookup", func(t *testing.T) { + got, err := destinationTypeForPolicyCheck("", false, &destinationConfigFlags{URL: "https://x"}, func() (*hookdeck.Destination, error) { + t.Fatal("must not spend an API call when no policy flag is set") + return nil, nil + }) + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("--config takes precedence so the individual flags are ignored", func(t *testing.T) { + got, err := destinationTypeForPolicyCheck("", true, policyFlags, func() (*hookdeck.Destination, error) { + t.Fatal("must not spend an API call when --config wins anyway") + return nil, nil + }) + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("no stored destination leaves the type to the API", func(t *testing.T) { + got, err := destinationTypeForPolicyCheck("", false, policyFlags, func() (*hookdeck.Destination, error) { + return nil, nil + }) + require.NoError(t, err) + assert.Equal(t, "", got) + }) + + t.Run("a failed lookup is an error, not a pass", func(t *testing.T) { + _, err := destinationTypeForPolicyCheck("", false, policyFlags, func() (*hookdeck.Destination, error) { + return nil, errors.New("network down") + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "network down") + }) +} + +// TestUpsertRejectsDeliveryPolicyOnStoredCLIDestination walks the whole path the +// command takes: build the config from flags with --type omitted, resolve the +// stored type over the API, then apply the guard. +func TestUpsertRejectsDeliveryPolicyOnStoredCLIDestination(t *testing.T) { + client := destinationAPI(t, &hookdeck.Destination{ID: "des_1", Name: "local", Type: "CLI"}, 0) + flags := &destinationConfigFlags{RateLimit: 100, RateLimitPeriod: "minute"} + + // --type omitted, exactly as `destination upsert local --rate-limit 100 ...`. + config, err := buildDestinationConfigFromFlags("", "", "", flags) + require.NoError(t, err) + require.Contains(t, config, "delivery_policy", "the flags do build a policy; the type is what makes it useless") + + policyType, err := destinationTypeForPolicyCheck("", false, flags, func() (*hookdeck.Destination, error) { + return fetchDestinationByName(context.Background(), client, "local") + }) + require.NoError(t, err) + + err = rejectDeliveryPolicyInConfigForCLI(policyType, config, "") + require.Error(t, err, "a stored CLI destination must refuse delivery-policy flags even when --type is omitted") + assert.Contains(t, err.Error(), "CLI destinations") +} + +// TestUpsertAcceptsDeliveryPolicyOnStoredHTTPDestination is the other half. +func TestUpsertAcceptsDeliveryPolicyOnStoredHTTPDestination(t *testing.T) { + client := destinationAPI(t, &hookdeck.Destination{ID: "des_2", Name: "web", Type: "HTTP"}, 0) + flags := &destinationConfigFlags{RateLimit: 100, RateLimitPeriod: "minute"} + + config, err := buildDestinationConfigFromFlags("", "", "", flags) + require.NoError(t, err) + + policyType, err := destinationTypeForPolicyCheck("", false, flags, func() (*hookdeck.Destination, error) { + return fetchDestinationByName(context.Background(), client, "web") + }) + require.NoError(t, err) + assert.Equal(t, "HTTP", policyType) + assert.NoError(t, rejectDeliveryPolicyInConfigForCLI(policyType, config, "")) +} + +// TestFetchDestinationByName pins the (nil, nil) contract the callers rely on to +// tell "no such destination yet" apart from "the lookup failed". +func TestFetchDestinationByName(t *testing.T) { + t.Run("found", func(t *testing.T) { + client := destinationAPI(t, &hookdeck.Destination{ID: "des_1", Name: "local", Type: "CLI"}, 0) + got, err := fetchDestinationByName(context.Background(), client, "local") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "CLI", got.Type) + }) + + t.Run("not found", func(t *testing.T) { + client := destinationAPI(t, nil, 0) + got, err := fetchDestinationByName(context.Background(), client, "nope") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("lookup failure", func(t *testing.T) { + client := destinationAPI(t, nil, http.StatusInternalServerError) + _, err := fetchDestinationByName(context.Background(), client, "local") + require.Error(t, err) + }) +} + +// TestApplyStoredDestinationConfigFailsClosedOnLookupError pins the second half +// of the #393 fix. The lookup that carries delivery_policy.groups.overrides +// forward used to ignore its own errors: a transient failure left the bare +// groups object in the request, the PUT succeeded, and the stored overrides were +// gone with nothing reporting it. +func TestApplyStoredDestinationConfigFailsClosedOnLookupError(t *testing.T) { + bareGroups := groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + }) + req := &hookdeck.DestinationCreateRequest{Name: "web", Config: bareGroups} + + err := applyStoredDestinationConfig("web", req, func() (*hookdeck.Destination, error) { + return nil, errors.New("503 Service Unavailable") + }) + + require.Error(t, err, "a bare groups object must not be sent when the stored overrides could not be read") + assert.Contains(t, err.Error(), "overrides") + assert.Contains(t, err.Error(), "503 Service Unavailable") +} + +// TestApplyStoredDestinationConfigLookupErrorPaths covers the surrounding cases, +// including the partial-update path that may legitimately continue on error. +func TestApplyStoredDestinationConfigLookupErrorPaths(t *testing.T) { + t.Run("partial update still continues on a lookup failure", func(t *testing.T) { + req := &hookdeck.DestinationCreateRequest{Name: "web"} + err := applyStoredDestinationConfig("web", req, func() (*hookdeck.Destination, error) { + return nil, errors.New("503 Service Unavailable") + }) + require.NoError(t, err, "there is nothing to destroy here; the API rejecting a config-less PUT is visible") + assert.Empty(t, req.Config) + }) + + t.Run("stored overrides are carried forward when the lookup succeeds", func(t *testing.T) { + req := &hookdeck.DestinationCreateRequest{ + Name: "web", + Config: groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + }), + } + stored := groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": storedOverrides(), + }) + err := applyStoredDestinationConfig("web", req, func() (*hookdeck.Destination, error) { + return &hookdeck.Destination{ID: "des_2", Name: "web", Type: "HTTP", Config: stored}, nil + }) + require.NoError(t, err) + + groups, ok := nestedMap(req.Config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverrides(), groups["overrides"]) + assert.Equal(t, 30, groups["rate"], "the caller's new rate must still win") + }) + + t.Run("a create is not blocked by the absence of a stored destination", func(t *testing.T) { + req := &hookdeck.DestinationCreateRequest{ + Name: "brand-new", + Config: groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + }), + } + err := applyStoredDestinationConfig("brand-new", req, func() (*hookdeck.Destination, error) { + return nil, nil + }) + require.NoError(t, err) + }) + + t.Run("a config with its own overrides needs no lookup at all", func(t *testing.T) { + req := &hookdeck.DestinationCreateRequest{ + Name: "web", + Config: groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + "overrides": map[string]interface{}{"cust_2": map[string]interface{}{"rate": 1}}, + }), + } + err := applyStoredDestinationConfig("web", req, func() (*hookdeck.Destination, error) { + t.Fatal("must not spend an API call when the caller supplied overrides") + return nil, nil + }) + require.NoError(t, err) + }) + + t.Run("partial update adopts the stored config and type", func(t *testing.T) { + req := &hookdeck.DestinationCreateRequest{Name: "local"} + err := applyStoredDestinationConfig("local", req, func() (*hookdeck.Destination, error) { + return &hookdeck.Destination{ + ID: "des_1", Name: "local", Type: "CLI", + Config: map[string]interface{}{"path": "/webhooks"}, + }, nil + }) + require.NoError(t, err) + assert.Equal(t, "CLI", req.Type) + assert.Equal(t, "/webhooks", req.Config["path"]) + }) +} + +// TestPreserveStoredDeliveryGroupOverrides pins the update path's share of #393. +// `destination update` builds a full groups object from the flags — the CLI +// requires --delivery-group-key and --delivery-group-rate-period whenever +// --delivery-group-rate is given, so "just bump the rate" always sends one — and +// never looked the stored overrides up at all, so the PUT replaced groups +// wholesale and took them with it. +func TestPreserveStoredDeliveryGroupOverrides(t *testing.T) { + bumpedRate := func() map[string]interface{} { + return groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + }) + } + stored := func() *hookdeck.Destination { + return &hookdeck.Destination{ + ID: "des_1", Name: "web", Type: "HTTP", + Config: groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": storedOverrides(), + }), + } + } + + t.Run("a rate bump carries the stored overrides forward", func(t *testing.T) { + config := bumpedRate() + err := preserveStoredDeliveryGroupOverrides("des_1", config, func() (*hookdeck.Destination, error) { + return stored(), nil + }) + require.NoError(t, err) + + groups, ok := nestedMap(config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverrides(), groups["overrides"], "the stored overrides must survive the PUT") + assert.Equal(t, 30, groups["rate"], "the caller's new rate must still win") + }) + + t.Run("a failed lookup refuses rather than wiping the overrides", func(t *testing.T) { + config := bumpedRate() + err := preserveStoredDeliveryGroupOverrides("des_1", config, func() (*hookdeck.Destination, error) { + return nil, errors.New("503 Service Unavailable") + }) + require.Error(t, err, "sending the bare groups object would destroy the overrides and the PUT would succeed") + assert.Contains(t, err.Error(), "overrides") + assert.Contains(t, err.Error(), "503 Service Unavailable") + }) + + t.Run("overrides supplied by the caller need no lookup", func(t *testing.T) { + config := groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 30, "rate_period": "second", + "overrides": map[string]interface{}{"cust_2": map[string]interface{}{"rate": 1}}, + }) + err := preserveStoredDeliveryGroupOverrides("des_1", config, func() (*hookdeck.Destination, error) { + t.Fatal("must not spend an API call when the caller supplied overrides") + return nil, nil + }) + require.NoError(t, err) + }) + + t.Run("a config that sets no groups needs no lookup", func(t *testing.T) { + config := map[string]interface{}{"url": "https://api.example.com"} + err := preserveStoredDeliveryGroupOverrides("des_1", config, func() (*hookdeck.Destination, error) { + t.Fatal("must not spend an API call when no delivery group is being sent") + return nil, nil + }) + require.NoError(t, err) + }) + + t.Run("a stored destination with no overrides is left alone", func(t *testing.T) { + config := bumpedRate() + err := preserveStoredDeliveryGroupOverrides("des_1", config, func() (*hookdeck.Destination, error) { + return &hookdeck.Destination{ID: "des_1", Name: "web", Type: "HTTP", + Config: groupsConfig(map[string]interface{}{"key": "body.customer_id"})}, nil + }) + require.NoError(t, err) + + groups, ok := nestedMap(config, "delivery_policy", "groups") + require.True(t, ok) + _, has := groups["overrides"] + assert.False(t, has, "nothing stored means nothing to carry forward") + }) +} + +// TestDestinationUpdateSharesOneLookup pins that the type resolution and the +// overrides preservation reuse a single GET. They are independent guards over +// the same record, and paying twice for it on every rate bump would be a +// regression in its own right. +func TestDestinationUpdateSharesOneLookup(t *testing.T) { + calls := 0 + lookup := func() (*hookdeck.Destination, error) { + calls++ + return &hookdeck.Destination{ + ID: "des_1", Name: "web", Type: "HTTP", + Config: groupsConfig(map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": storedOverrides(), + }), + }, nil + } + // Memoised exactly as runDestinationUpdateCmd does it. + var ( + cached *hookdeck.Destination + fetched bool + ) + memoised := func() (*hookdeck.Destination, error) { + if fetched { + return cached, nil + } + found, err := lookup() + if err != nil { + return nil, err + } + cached, fetched = found, true + return found, nil + } + + flags := &destinationConfigFlags{ + DeliveryGroupKey: "body.customer_id", + DeliveryGroupRate: 30, + DeliveryGroupRatePeriod: "second", + } + config, err := buildDestinationConfigFromFlags("", "", "", flags) + require.NoError(t, err) + + policyType, err := destinationTypeForPolicyCheck("", false, flags, memoised) + require.NoError(t, err) + require.NoError(t, rejectDeliveryPolicyInConfigForCLI(policyType, config, "")) + require.NoError(t, preserveStoredDeliveryGroupOverrides("des_1", config, memoised)) + + assert.Equal(t, 1, calls, "both guards must share one GET") + groups, ok := nestedMap(config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverrides(), groups["overrides"]) +} + +// storedDestServer serves GET /destinations (list by name) and +// GET /destinations/{id}, so the builders below exercise the real lookup path. +// failAfter makes every request from the nth onwards fail, which is how a +// transient outage is reproduced. +func storedDestServer(t *testing.T, stored *hookdeck.Destination, failAfter int) (*hookdeck.Client, *int) { + t.Helper() + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if failAfter > 0 && calls >= failAfter { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"message":"503 Service Unavailable"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == hookdeck.APIPathPrefix+"/destinations" { + models := []hookdeck.Destination{} + if stored != nil { + models = append(models, *stored) + } + _ = json.NewEncoder(w).Encode(hookdeck.DestinationListResponse{Models: models}) + return + } + _ = json.NewEncoder(w).Encode(stored) + })) + t.Cleanup(srv.Close) + + baseURL, err := url.Parse(srv.URL) + require.NoError(t, err) + return &hookdeck.Client{BaseURL: baseURL, APIKey: "k"}, &calls +} + +func storedHTTPDestWithOverrides() *hookdeck.Destination { + return &hookdeck.Destination{ + ID: "des_1", Name: "web", Type: "HTTP", + Config: map[string]interface{}{ + "url": "https://api.example.com", + "delivery_policy": map[string]interface{}{ + "groups": map[string]interface{}{ + "key": "body.customer_id", "rate": 10, "rate_period": "second", + "overrides": storedOverrides(), + }, + }, + }, + } +} + +// groupRateBumpFlags is the invocation that always lost the overrides: the CLI +// requires --delivery-group-key and --delivery-group-rate-period whenever +// --delivery-group-rate is given, so bumping the rate always sends a full +// groups object. +// storedOverridesOverTheWire is storedOverrides() as it comes back from JSON, +// where every number is a float64. +func storedOverridesOverTheWire() map[string]interface{} { + return map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": float64(5), "rate_period": "minute"}, + } +} + +func groupRateBumpFlags() destinationConfigFlags { + return destinationConfigFlags{ + DeliveryGroupKey: "body.customer_id", + DeliveryGroupRate: 30, + DeliveryGroupRatePeriod: "second", + } +} + +// TestDestinationUpdateBuildsRequestPreservingOverrides pins the wiring, not the +// helper: `destination update` never looked the stored overrides up at all, so +// the PUT replaced delivery_policy.groups wholesale and took them with it. +func TestDestinationUpdateBuildsRequestPreservingOverrides(t *testing.T) { + t.Run("a rate bump carries the stored overrides into the request", func(t *testing.T) { + client, calls := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpdateCmd{destinationConfigFlags: groupRateBumpFlags()} + + req, err := dc.buildUpdateRequest(context.Background(), client, "des_1") + require.NoError(t, err) + + groups, ok := nestedMap(req.Config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverridesOverTheWire(), groups["overrides"], "the PUT must not drop the stored overrides") + assert.Equal(t, 30, groups["rate"]) + assert.Equal(t, 1, *calls, "the type guard and the overrides lookup must share one GET") + }) + + t.Run("a failed lookup refuses instead of sending a bare groups object", func(t *testing.T) { + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 1) + // --type given, so the type guard needs no lookup and the overrides + // lookup is the only one left to fail. Without --type the command also + // refuses, but over the type guard's error rather than this one. + dc := &destinationUpdateCmd{destType: "HTTP", destinationConfigFlags: groupRateBumpFlags()} + + _, err := dc.buildUpdateRequest(context.Background(), client, "des_1") + require.Error(t, err, "continuing would wipe the overrides and the PUT would still succeed") + assert.Contains(t, err.Error(), "overrides") + }) + + t.Run("delivery policy flags are refused on a stored CLI destination", func(t *testing.T) { + client, _ := storedDestServer(t, &hookdeck.Destination{ID: "des_2", Name: "local", Type: "CLI"}, 0) + dc := &destinationUpdateCmd{destinationConfigFlags: destinationConfigFlags{ + RateLimit: 100, RateLimitPeriod: "minute", + }} + + // --type omitted, which is the ordinary form of the command. + _, err := dc.buildUpdateRequest(context.Background(), client, "des_2") + require.Error(t, err, "the API accepts the request and discards the policy, so the CLI has to refuse it") + assert.Contains(t, err.Error(), "CLI destinations") + }) + + t.Run("an unrelated update spends no lookup at all", func(t *testing.T) { + client, calls := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpdateCmd{description: "just a note"} + + req, err := dc.buildUpdateRequest(context.Background(), client, "des_1") + require.NoError(t, err) + require.NotNil(t, req.Description) + assert.Equal(t, 0, *calls) + }) +} + +// TestDestinationUpsertBuildsRequestPreservingOverrides is the upsert half of the +// same wiring. The lookup used to ignore its own errors, so a transient failure +// left the bare groups object in the request and the overrides were destroyed +// with nothing reporting it (#393). +func TestDestinationUpsertBuildsRequestPreservingOverrides(t *testing.T) { + t.Run("a rate bump carries the stored overrides into the request", func(t *testing.T) { + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpsertCmd{name: "web", destinationConfigFlags: groupRateBumpFlags()} + + req, err := dc.buildUpsertRequest(context.Background(), client) + require.NoError(t, err) + + groups, ok := nestedMap(req.Config, "delivery_policy", "groups") + require.True(t, ok) + assert.Equal(t, storedOverridesOverTheWire(), groups["overrides"]) + }) + + t.Run("a failed lookup refuses instead of sending a bare groups object", func(t *testing.T) { + // failAfter 2: the list call succeeds and the GET that follows fails. + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 2) + dc := &destinationUpsertCmd{name: "web", destType: "HTTP", destinationConfigFlags: groupRateBumpFlags()} + + _, err := dc.buildUpsertRequest(context.Background(), client) + require.Error(t, err) + assert.Contains(t, err.Error(), "overrides") + }) + + t.Run("delivery policy flags are refused on a stored CLI destination", func(t *testing.T) { + client, _ := storedDestServer(t, &hookdeck.Destination{ID: "des_2", Name: "local", Type: "CLI"}, 0) + dc := &destinationUpsertCmd{name: "local", destinationConfigFlags: destinationConfigFlags{ + RateLimit: 100, RateLimitPeriod: "minute", + }} + + _, err := dc.buildUpsertRequest(context.Background(), client) + require.Error(t, err) + assert.Contains(t, err.Error(), "CLI destinations") + }) + + t.Run("a partial update still adopts the stored config", func(t *testing.T) { + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpsertCmd{name: "web", description: "just a note"} + + req, err := dc.buildUpsertRequest(context.Background(), client) + require.NoError(t, err) + assert.Equal(t, "HTTP", req.Type) + assert.Equal(t, "https://api.example.com", req.Config["url"]) + }) +} diff --git a/pkg/cmd/destination_update.go b/pkg/cmd/destination_update.go index 21d27b66..bcb5df24 100644 --- a/pkg/cmd/destination_update.go +++ b/pkg/cmd/destination_update.go @@ -99,28 +99,10 @@ func (dc *destinationUpdateCmd) runDestinationUpdateCmd(cmd *cobra.Command, args client := Config.GetAPIClient() ctx := context.Background() - dc.destinationConfigFlags.URL = dc.url - dc.destinationConfigFlags.CliPath = dc.cliPath - - req := &hookdeck.DestinationUpdateRequest{} - req.Name = dc.name - if dc.description != "" { - req.Description = &dc.description - } - if dc.destType != "" { - req.Type = strings.ToUpper(dc.destType) - } - config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + req, err := dc.buildUpdateRequest(ctx, client, destID) if err != nil { return err } - if len(config) > 0 { - req.Config = config - } - - if destinationUpdateRequestEmpty(req) { - return fmt.Errorf("no updates specified (set at least one of --name, --description, --type, or config flags)") - } dst, err := client.UpdateDestination(ctx, destID, req) if err != nil { @@ -144,3 +126,74 @@ func (dc *destinationUpdateCmd) runDestinationUpdateCmd(cmd *cobra.Command, args } return nil } + +// buildUpdateRequest assembles the PUT body, consulting the stored destination +// where a flag alone cannot answer the question. Separated from the command so +// the guards it applies are reachable from a test with a real HTTP client. +func (dc *destinationUpdateCmd) buildUpdateRequest(ctx context.Context, client *hookdeck.Client, destID string) (*hookdeck.DestinationUpdateRequest, error) { + dc.destinationConfigFlags.URL = dc.url + dc.destinationConfigFlags.CliPath = dc.cliPath + + req := &hookdeck.DestinationUpdateRequest{} + req.Name = dc.name + if dc.description != "" { + req.Description = &dc.description + } + if dc.destType != "" { + req.Type = strings.ToUpper(dc.destType) + } + // The stored destination answers two questions below: what type it is, so + // the CLI delivery-policy guard can run when --type is omitted, and what + // delivery-group overrides it holds, so a bare groups object does not + // destroy them. Fetch it at most once, and only when it is needed. + var ( + existingDest *hookdeck.Destination + fetchedExisting bool + ) + lookupExisting := func() (*hookdeck.Destination, error) { + if fetchedExisting { + return existingDest, nil + } + found, err := client.GetDestination(ctx, destID, nil) + if err != nil { + return nil, err + } + existingDest, fetchedExisting = found, true + return found, nil + } + + // --type is normally omitted on update, so the delivery-policy guard has to + // resolve the stored type or it never fires for the common invocation. + policyType, err := destinationTypeForPolicyCheck( + dc.destType, + dc.config != "" || dc.configFile != "", + &dc.destinationConfigFlags, + lookupExisting, + ) + if err != nil { + return nil, err + } + + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + if err != nil { + return nil, err + } + if err := rejectDeliveryPolicyInConfigForCLI(policyType, config, ""); err != nil { + return nil, err + } + // update is a PUT and the API replaces delivery_policy.groups wholesale, so + // "just bump the group rate" was destroying the stored overrides here just + // as it was on upsert (#393). + if err := preserveStoredDeliveryGroupOverrides(destID, config, lookupExisting); err != nil { + return nil, err + } + if len(config) > 0 { + req.Config = config + } + + if destinationUpdateRequestEmpty(req) { + return nil, fmt.Errorf("no updates specified (set at least one of --name, --description, --type, or config flags)") + } + + return req, nil +} diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index ef774aaa..35925177 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -96,60 +96,11 @@ func (dc *destinationUpsertCmd) runDestinationUpsertCmd(cmd *cobra.Command, args client := Config.GetAPIClient() ctx := context.Background() - dc.destinationConfigFlags.URL = dc.url - dc.destinationConfigFlags.CliPath = dc.cliPath - - config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + req, err := dc.buildUpsertRequest(ctx, client) if err != nil { return err } - t := strings.ToUpper(dc.destType) - if config == nil { - config = make(map[string]interface{}) - } - if t == "HTTP" && dc.url != "" { - config["url"] = dc.url - } - if t == "CLI" { - applyCLIPath(config, dc.cliPath, false) - } - - req := &hookdeck.DestinationCreateRequest{ - Name: dc.name, - } - if dc.description != "" { - req.Description = &dc.description - } - if t != "" { - req.Type = t - } - if len(config) > 0 { - req.Config = config - } - - // API requires config on PUT. When doing partial update (e.g. only --description), fetch existing and merge. - // A groups object sent without overrides also needs the stored config, because - // the API replaces groups wholesale and would drop the overrides with it. - needsOverrides := deliveryGroupsNeedOverrides(req.Config) - if req.Config == nil || len(req.Config) == 0 || needsOverrides { - params := map[string]string{"name": dc.name} - listResp, err := client.ListDestinations(ctx, params) - if err == nil && listResp.Models != nil && len(listResp.Models) > 0 { - existing, err := client.GetDestination(ctx, listResp.Models[0].ID, nil) - if err == nil && existing.Config != nil { - if len(req.Config) == 0 { - req.Config = existing.Config - if req.Type == "" { - req.Type = existing.Type - } - } else { - preserveDeliveryGroupOverrides(req.Config, existing.Config) - } - } - } - } - if dc.dryRun { params := map[string]string{"name": dc.name} existing, err := client.ListDestinations(ctx, params) @@ -186,3 +137,84 @@ func (dc *destinationUpsertCmd) runDestinationUpsertCmd(cmd *cobra.Command, args } return nil } + +// buildUpsertRequest assembles the PUT body, consulting the stored destination +// where a flag alone cannot answer the question. Separated from the command so +// the guards it applies are reachable from a test with a real HTTP client. +func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client *hookdeck.Client) (*hookdeck.DestinationCreateRequest, error) { + dc.destinationConfigFlags.URL = dc.url + dc.destinationConfigFlags.CliPath = dc.cliPath + + // The stored destination answers two separate questions below: what type it + // is, so the CLI delivery-policy guard can run when --type is omitted, and + // what delivery-group overrides it holds, so a bare groups object does not + // destroy them. Fetch it at most once, and only when it is needed. + var ( + existingDest *hookdeck.Destination + fetchedExisting bool + ) + lookupExisting := func() (*hookdeck.Destination, error) { + if fetchedExisting { + return existingDest, nil + } + found, err := fetchDestinationByName(ctx, client, dc.name) + if err != nil { + return nil, err + } + existingDest, fetchedExisting = found, true + return found, nil + } + + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + if err != nil { + return nil, err + } + + // --type is normally omitted on upsert, so the delivery-policy guard has to + // resolve the stored type or it never fires for the common invocation. + policyType, err := destinationTypeForPolicyCheck( + dc.destType, + dc.config != "" || dc.configFile != "", + &dc.destinationConfigFlags, + lookupExisting, + ) + if err != nil { + return nil, err + } + if err := rejectDeliveryPolicyInConfigForCLI(policyType, config, ""); err != nil { + return nil, err + } + + t := strings.ToUpper(dc.destType) + if config == nil { + config = make(map[string]interface{}) + } + if t == "HTTP" && dc.url != "" { + config["url"] = dc.url + } + if t == "CLI" { + applyCLIPath(config, dc.cliPath, false) + } + + req := &hookdeck.DestinationCreateRequest{ + Name: dc.name, + } + if dc.description != "" { + req.Description = &dc.description + } + if t != "" { + req.Type = t + } + if len(config) > 0 { + req.Config = config + } + + // API requires config on PUT. When doing partial update (e.g. only --description), fetch existing and merge. + // A groups object sent without overrides also needs the stored config, because + // the API replaces groups wholesale and would drop the overrides with it. + if err := applyStoredDestinationConfig(dc.name, req, lookupExisting); err != nil { + return nil, err + } + + return req, nil +} diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index 32db14f9..abeb8abb 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -67,6 +67,13 @@ func hasDimension(params hookdeck.MetricsQueryParams, name string) bool { // queryEventMetricsConsolidated routes to the correct underlying API endpoint // based on the requested measures and dimensions. func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, params hookdeck.MetricsQueryParams) (hookdeck.MetricsResponse, error) { + // Only one endpoint is called, so measures belonging to different ones + // cannot all be answered: the surplus would be dropped or rewritten into a + // 422. Refuse the combination by name rather than exit 0 with less data + // than was asked for. + if err := hookdeck.RejectMixedMeasureRoutes(params.Measures, "--measures"); err != nil { + return nil, err + } // Route based on measures/dimensions: // 1. If measures include queue_depth, max_depth, or max_age → QueryQueueDepth if hasMeasure(params, queueDepthMeasures) { diff --git a/pkg/cmd/metrics_events_routing_test.go b/pkg/cmd/metrics_events_routing_test.go index 877364cd..a9cd8224 100644 --- a/pkg/cmd/metrics_events_routing_test.go +++ b/pkg/cmd/metrics_events_routing_test.go @@ -83,3 +83,76 @@ func TestTranslateQueueDepthMeasures(t *testing.T) { assert.Equal(t, []string{"max_depth", "max_age"}, hookdeck.TranslateQueueDepthMeasures([]string{"queue_depth", "max_age"})) assert.Empty(t, hookdeck.TranslateQueueDepthMeasures(nil)) } + +// TestMixedMeasureRoutesAreRejected pins the guard against a measure list that +// spans more than one endpoint. Only one endpoint is called, so the surplus +// measures were either dropped (pending replaces the list with "count") or +// rewritten into a 422 (queue_depth becomes max_depth). Both looked like a +// successful answer to a question that was never asked. +func TestMixedMeasureRoutesAreRejected(t *testing.T) { + tests := []struct { + name string + measures []string + contains []string + }{ + { + name: "pending with a default-route measure", + measures: []string{"pending", "failed_count"}, + contains: []string{"--measures", `"pending"`, `"failed_count"`, "pending event metrics", "event metrics"}, + }, + { + name: "default-route measure with queue depth", + measures: []string{"count", "queue_depth"}, + contains: []string{`"count"`, `"queue_depth"`, "queue depth metrics"}, + }, + { + name: "pending with queue depth", + measures: []string{"max_age", "pending"}, + contains: []string{`"max_age"`, `"pending"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, path, _ := routeCapture(t) + + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: tt.measures}) + require.Error(t, err, "a cross-route measure list must not be answered from one endpoint") + for _, want := range tt.contains { + assert.Contains(t, err.Error(), want) + } + assert.Empty(t, *path, "the API must not be called at all") + }) + } +} + +// TestSingleRouteMeasureCombinationsStillWork is the other half: measures that +// all belong to one endpoint must still be sent together. +func TestSingleRouteMeasureCombinationsStillWork(t *testing.T) { + t.Run("default route", func(t *testing.T) { + client, path, query := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{"count", "failed_count", "error_rate"}}) + require.NoError(t, err) + assert.Contains(t, *path, "metrics/events") + assert.Equal(t, []string{"count", "failed_count", "error_rate"}, (*query)["measures[]"]) + }) + + t.Run("queue depth route", func(t *testing.T) { + client, path, query := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{"queue_depth", "max_age"}}) + require.NoError(t, err) + assert.Contains(t, *path, "queue-depth") + assert.Equal(t, []string{"max_depth", "max_age"}, (*query)["measures[]"]) + }) + + t.Run("a measure this package does not know does not trigger the guard", func(t *testing.T) { + client, path, _ := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{"count", "not_a_measure"}}) + require.NoError(t, err, "an unknown measure is the API's to reject, with a better message") + assert.Contains(t, *path, "metrics/events") + }) +} diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index 16a5219c..045477e4 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -102,6 +102,12 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp return ErrorResult(err.Error()), nil } + // Only one endpoint is called, so measures belonging to different ones + // cannot all be answered. Shared with the CLI so the two cannot drift. + if err := hookdeck.RejectMixedMeasureRoutes(params.Measures, "measures"); err != nil { + return ErrorResult(err.Error()), nil + } + // Route to the correct events metrics endpoint based on measures/dimensions. // Each route accepts a different set of filters, so the ones it would ignore // are refused here rather than silently dropped by the API. diff --git a/pkg/gateway/mcp/tool_metrics_measures_test.go b/pkg/gateway/mcp/tool_metrics_measures_test.go new file mode 100644 index 00000000..7d07636a --- /dev/null +++ b/pkg/gateway/mcp/tool_metrics_measures_test.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestMetricsToolRejectsMixedMeasureRoutes is the MCP counterpart of +// TestMixedMeasureRoutesAreRejected. Routing picks one endpoint from the +// measures, so a list spanning two of them cannot be answered: the surplus was +// dropped or rewritten into a 422 while the call still reported success. Both +// layers call the same helper so they cannot drift. +func TestMetricsToolRejectsMixedMeasureRoutes(t *testing.T) { + tests := []struct { + name string + measures []any + contains []string + }{ + { + name: "pending with a default-route measure", + measures: []any{"pending", "failed_count"}, + contains: []string{"measures", `"pending"`, `"failed_count"`}, + }, + { + name: "default-route measure with queue depth", + measures: []any{"count", "queue_depth"}, + contains: []string{`"count"`, `"queue_depth"`, "queue depth metrics"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with measures spanning two endpoints", r.URL.Path) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": fail, + hookdeck.APIPathPrefix + "/metrics/queue-depth": fail, + hookdeck.APIPathPrefix + "/metrics/events-pending-timeseries": fail, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": tt.measures, + }) + + assert.True(t, result.IsError, "a cross-route measure list must be refused") + body := textContent(t, result) + for _, want := range tt.contains { + assert.Contains(t, body, want) + } + }) + } +} + +// TestMetricsToolAcceptsSingleRouteMeasures is the other half: measures that all +// belong to one endpoint must still be sent together. +func TestMetricsToolAcceptsSingleRouteMeasures(t *testing.T) { + var sawMeasures []string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": func(w http.ResponseWriter, r *http.Request) { + sawMeasures = r.URL.Query()["measures[]"] + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count", "failed_count"}, + }) + + assert.False(t, result.IsError) + assert.Equal(t, []string{"count", "failed_count"}, sawMeasures) +} diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index 539ccffd..759dd13c 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -122,3 +122,63 @@ func TranslateQueueDepthMeasures(measures []string) []string { } return out } + +// Names of the events-metrics routes, as they appear to the caller in errors. +const ( + EventRouteDefault = "event metrics" + EventRouteQueueDepth = "queue depth metrics" + EventRoutePending = "pending event metrics" +) + +// eventMeasureRoutes maps every measure `metrics events` advertises onto the API +// endpoint that measure selects. The by-issue route is chosen by dimension +// rather than by measure, so it has no entry here. +// +// A measure that is absent from this map does not influence routing: the +// request goes to the default endpoint and the API rejects the measure itself, +// which is a better error than one this package could invent. +var eventMeasureRoutes = map[string]string{ + "count": EventRouteDefault, + "successful_count": EventRouteDefault, + "failed_count": EventRouteDefault, + "scheduled_count": EventRouteDefault, + "paused_count": EventRouteDefault, + "error_rate": EventRouteDefault, + "avg_attempts": EventRouteDefault, + "scheduled_retry_count": EventRouteDefault, + + "queue_depth": EventRouteQueueDepth, + "max_depth": EventRouteQueueDepth, + "max_age": EventRouteQueueDepth, + + "pending": EventRoutePending, +} + +// RejectMixedMeasureRoutes refuses a measure list that spans more than one +// events-metrics endpoint. +// +// Routing picks a single endpoint from the measures, so a mixed list is not a +// combined query: the extra measures are either silently dropped (the pending +// route replaces the whole list with "count") or rewritten into something the +// endpoint rejects with a 422. Neither is what the caller asked for, so say so +// here instead. measuresName is the caller's own spelling of the argument, so a +// CLI user reads "--measures" and an MCP client reads "measures". +func RejectMixedMeasureRoutes(measures []string, measuresName string) error { + firstMeasure := "" + firstRoute := "" + for _, m := range measures { + route, known := eventMeasureRoutes[m] + if !known { + continue + } + if firstRoute == "" { + firstMeasure, firstRoute = m, route + continue + } + if route != firstRoute { + return fmt.Errorf("%s cannot mix %q (%s) with %q (%s): these are separate API endpoints, so ask for one route's measures at a time", + measuresName, firstMeasure, firstRoute, m, route) + } + } + return nil +} diff --git a/test/acceptance/connection_delivery_group_test.go b/test/acceptance/connection_delivery_group_test.go new file mode 100644 index 00000000..dc8fc75b --- /dev/null +++ b/test/acceptance/connection_delivery_group_test.go @@ -0,0 +1,325 @@ +//go:build connection_upsert + +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// `gateway connection` carries the same delivery-policy flags as +// `gateway destination`, under a --destination- prefix. The create and upsert +// paths build the destination config through different code, so both need +// covering: connection upsert reaches the stored destination through the +// connection, not through a destination lookup. + +// connectionDestinationDeliveryGroups extracts +// destination.config.delivery_policy.groups from a connection response body. +func connectionDestinationDeliveryGroups(t *testing.T, resp map[string]interface{}) map[string]interface{} { + t.Helper() + dest, ok := resp["destination"].(map[string]interface{}) + require.True(t, ok, "expected destination object on connection, got %T", resp["destination"]) + config, ok := dest["config"].(map[string]interface{}) + require.True(t, ok, "expected destination config object, got %T", dest["config"]) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok, "expected destination config.delivery_policy object, got %v", config["delivery_policy"]) + groups, ok := policy["groups"].(map[string]interface{}) + require.True(t, ok, "expected config.delivery_policy.groups object, got %v", policy["groups"]) + return groups +} + +// cleanupConnectionAndResources deletes the connection and the source and +// destination it created inline. `gateway connection delete` removes only the +// connection, so tests that create resources inline have to delete them too — +// the suite already leaks ~36k sources and destinations (#362). +func cleanupConnectionAndResources(t *testing.T, cli *CLIRunner, resp map[string]interface{}) { + t.Helper() + id := func(key string) string { + nested, ok := resp[key].(map[string]interface{}) + if !ok { + return "" + } + s, _ := nested["id"].(string) + return s + } + connID, _ := resp["id"].(string) + srcID, dstID := id("source"), id("destination") + + t.Cleanup(func() { + if connID != "" { + deleteConnection(t, cli, connID) + } + if dstID != "" { + deleteDestination(t, cli, dstID) + } + if srcID != "" { + deleteSource(t, cli, srcID) + } + }) +} + +// requireNoConnection asserts that no connection exists under the given name. +// Refusing the command is only half the contract; the other half is that +// nothing was created before the refusal. +func requireNoConnection(t *testing.T, cli *CLIRunner, name string) { + t.Helper() + stdout, stderr, err := cli.Run("gateway", "connection", "get", name) + if err == nil { + var resp map[string]interface{} + if jsonErr := cli.RunJSON(&resp, "gateway", "connection", "get", name); jsonErr == nil { + cleanupConnectionAndResources(t, cli, resp) + } + t.Fatalf("rejected command must not create a connection, but %q exists: %s", name, stdout) + } + assert.Contains(t, stdout+stderr, "connection not found", + "expected a not-found error for %q", name) +} + +// connectionSampleOverrides mirrors sampleOverrides in the destination tests; +// the two files can be compiled into the same binary, so the names differ. +func connectionSampleOverrides() map[string]interface{} { + return map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": float64(5), "rate_period": "minute"}, + "cust_2": map[string]interface{}{"rate": float64(50), "rate_period": "second"}, + } +} + +const connectionSampleOverridesJSON = `{"cust_1":{"rate":5,"rate_period":"minute"},"cust_2":{"rate":50,"rate_period":"second"}}` + +// TestConnectionCreateWithDestinationDeliveryGroup creates a connection whose +// inline destination carries a full delivery-group triple plus overrides, then +// reads it back and asserts the stored groups object matches exactly. +func TestConnectionCreateWithDestinationDeliveryGroup(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-create-" + timestamp + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dg-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dg-"+timestamp, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhooks", + "--destination-rate-limit", "100", + "--destination-rate-limit-period", "minute", + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + "--destination-delivery-group-overrides", connectionSampleOverridesJSON, + ) + require.NoError(t, err, "Should create connection with a destination delivery group") + require.NotEmpty(t, created["id"], "Expected connection ID") + cleanupConnectionAndResources(t, cli, created) + + // Read back rather than trusting the create response. + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "connection", "get", connName) + require.NoError(t, err, "Should read the connection back") + + assert.Equal(t, map[string]interface{}{ + "key": "body.customer_id", + "rate": float64(10), + "rate_period": "second", + "overrides": connectionSampleOverrides(), + }, connectionDestinationDeliveryGroups(t, fetched), + "stored delivery_policy.groups must match the flags exactly, overrides included") + + t.Logf("Created connection %s with destination delivery group overrides", connName) +} + +// TestConnectionUpsertPreservesDestinationDeliveryGroupOverrides is the #393 +// regression guard on the connection path. The API replaces +// delivery_policy.groups wholesale, so bumping only the group rate — which +// still has to repeat the key and rate-period — sends a full groups object and +// used to destroy the stored overrides. +func TestConnectionUpsertPreservesDestinationDeliveryGroupOverrides(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-upsert-" + timestamp + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dgu-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dgu-"+timestamp, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhooks", + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + "--destination-delivery-group-overrides", connectionSampleOverridesJSON, + ) + require.NoError(t, err, "Should create connection carrying overrides") + require.NotEmpty(t, created["id"], "Expected connection ID") + cleanupConnectionAndResources(t, cli, created) + + require.Equal(t, connectionSampleOverrides(), connectionDestinationDeliveryGroups(t, created)["overrides"], + "precondition: the destination must be created with overrides") + + // Change ONLY the group rate. + var upserted map[string]interface{} + err = cli.RunJSON(&upserted, "gateway", "connection", "upsert", connName, + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "30", + "--destination-delivery-group-rate-period", "second", + ) + require.NoError(t, err, "Should upsert the group rate") + + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "connection", "get", connName) + require.NoError(t, err, "Should read the connection back after upsert") + + groups := connectionDestinationDeliveryGroups(t, fetched) + assert.Equal(t, float64(30), groups["rate"], "the requested rate change must apply") + assert.Equal(t, "body.customer_id", groups["key"], "the group key must be unchanged") + assert.Equal(t, "second", groups["rate_period"], "the group rate period must be unchanged") + assert.Equal(t, connectionSampleOverrides(), groups["overrides"], + "bumping the group rate must not destroy the stored per-group overrides (#393)") +} + +// TestConnectionDestinationDeliveryPolicyRejectedForCLIType asserts the +// delivery-policy flags are refused against a CLI destination, and that no +// connection is created. A CLI destination has no delivery_policy in the API +// schema: the API accepts the request and discards the policy. +func TestConnectionDestinationDeliveryPolicyRejectedForCLIType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + }{ + {"rate limit", []string{"--destination-rate-limit", "100", "--destination-rate-limit-period", "minute"}}, + {"delivery group", []string{ + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + }}, + {"delivery group with overrides", []string{ + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + "--destination-delivery-group-overrides", connectionSampleOverridesJSON, + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-cli-" + timestamp + + args := append([]string{ + "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dgcli-" + timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dgcli-" + timestamp, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "delivery-policy flags must be refused for CLI destinations") + assert.Contains(t, stdout+stderr, "CLI destinations", + "the error should name CLI destinations as the reason") + + requireNoConnection(t, cli, connName) + }) + } +} + +// TestConnectionDestinationDeliveryPolicyPartialFlagsRejected asserts that an +// incomplete delivery-policy flag set is refused before any API call, and that +// nothing is created. +func TestConnectionDestinationDeliveryPolicyPartialFlagsRejected(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + wantError string + }{ + { + name: "group rate without key", + flags: []string{"--destination-delivery-group-rate", "10", "--destination-delivery-group-rate-period", "second"}, + wantError: "--destination-delivery-group-key", + }, + { + name: "group rate without rate period", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate", "10"}, + wantError: "--destination-delivery-group-rate-period", + }, + { + name: "overrides without a key", + flags: []string{"--destination-delivery-group-overrides", connectionSampleOverridesJSON}, + wantError: "--destination-delivery-group-key", + }, + { + name: "rate limit without period", + flags: []string{"--destination-rate-limit", "100"}, + wantError: "--destination-rate-limit-period", + }, + { + name: "negative group rate", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate=-5", "--destination-delivery-group-rate-period", "second"}, + wantError: "--destination-delivery-group-rate must be a positive integer", + }, + { + name: "negative rate limit", + flags: []string{"--destination-rate-limit=-5", "--destination-rate-limit-period", "minute"}, + wantError: "--destination-rate-limit must be a positive integer", + }, + { + name: "malformed overrides JSON", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate", "10", "--destination-delivery-group-rate-period", "second", "--destination-delivery-group-overrides", `{"cust_1": {"rate": 5`}, + wantError: "--destination-delivery-group-overrides must be a valid JSON object", + }, + { + name: "overrides as a JSON array", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate", "10", "--destination-delivery-group-rate-period", "second", "--destination-delivery-group-overrides", `[{"rate":5}]`}, + wantError: "--destination-delivery-group-overrides must be a valid JSON object", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-partial-" + timestamp + + args := append([]string{ + "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dgp-" + timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dgp-" + timestamp, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "an incomplete or invalid delivery-policy flag set must be refused") + assert.Contains(t, stdout+stderr, tc.wantError, + "the error should name the offending flag") + + requireNoConnection(t, cli, connName) + }) + } +} diff --git a/test/acceptance/destination_delivery_group_test.go b/test/acceptance/destination_delivery_group_test.go new file mode 100644 index 00000000..525eadd0 --- /dev/null +++ b/test/acceptance/destination_delivery_group_test.go @@ -0,0 +1,457 @@ +//go:build destination + +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Delivery groups are the headline feature of v2.6.0. A destination carries a +// config.delivery_policy with a destination-level rate/period and a nested +// groups object (key, rate, rate_period, overrides), driven by +// --delivery-group-key / --delivery-group-rate / --delivery-group-rate-period / +// --delivery-group-overrides on `gateway destination`. +// +// These tests exercise the real API, because the defects worth guarding against +// are all about what the API ends up storing, not about what the CLI builds in +// memory. + +// destinationDeliveryPolicy extracts config.delivery_policy from a destination +// response body. +func destinationDeliveryPolicy(t *testing.T, resp map[string]interface{}) map[string]interface{} { + t.Helper() + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "expected config object on destination, got %T", resp["config"]) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok, "expected config.delivery_policy object, got %v", config["delivery_policy"]) + return policy +} + +// destinationDeliveryGroups extracts config.delivery_policy.groups. +func destinationDeliveryGroups(t *testing.T, resp map[string]interface{}) map[string]interface{} { + t.Helper() + groups, ok := destinationDeliveryPolicy(t, resp)["groups"].(map[string]interface{}) + require.True(t, ok, "expected config.delivery_policy.groups object") + return groups +} + +// requireNoDestination asserts that no destination exists under the given name. +// Every rejection test uses this: refusing the command is only half the +// contract, the other half is that nothing was created before the refusal. +func requireNoDestination(t *testing.T, cli *CLIRunner, name string) { + t.Helper() + stdout, stderr, err := cli.Run("gateway", "destination", "get", name) + if err == nil { + // Something was created despite the rejection. Clean it up so the + // failure does not also leak a resource (#362), then fail. + var dst Destination + if jsonErr := cli.RunJSON(&dst, "gateway", "destination", "get", name); jsonErr == nil && dst.ID != "" { + deleteDestination(t, cli, dst.ID) + } + t.Fatalf("rejected command must not create a destination, but %q exists: %s", name, stdout) + } + assert.Contains(t, stdout+stderr, "no destination found", + "expected a not-found error for %q", name) +} + +// sampleOverrides is the overrides object used across these tests. Two entries, +// so a test cannot pass by preserving only the first. +func sampleOverrides() map[string]interface{} { + return map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": float64(5), "rate_period": "minute"}, + "cust_2": map[string]interface{}{"rate": float64(50), "rate_period": "second"}, + } +} + +const sampleOverridesJSON = `{"cust_1":{"rate":5,"rate_period":"minute"},"cust_2":{"rate":50,"rate_period":"second"}}` + +// TestDestinationCreateWithDeliveryGroup creates a destination with a +// destination-level rate limit and a full delivery-group triple plus +// overrides, then reads it back with --output json and asserts the stored +// delivery_policy matches exactly, nested overrides included. +func TestDestinationCreateWithDeliveryGroup(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dg-create-" + generateTimestamp() + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--rate-limit", "100", + "--rate-limit-period", "minute", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + ) + require.NoError(t, err, "Should create destination with a delivery group") + + dstID, ok := created["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + // Read back rather than trusting the create response. + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "destination", "get", dstID) + require.NoError(t, err, "Should read the destination back") + + policy := destinationDeliveryPolicy(t, fetched) + assert.Equal(t, float64(100), policy["rate"], "destination-level rate should be stored") + assert.Equal(t, "minute", policy["period"], "destination-level period should be stored") + + assert.Equal(t, map[string]interface{}{ + "key": "body.customer_id", + "rate": float64(10), + "rate_period": "second", + "overrides": sampleOverrides(), + }, destinationDeliveryGroups(t, fetched), + "stored delivery_policy.groups must match the flags exactly, overrides included") + + t.Logf("Created destination %s (ID: %s) with delivery group overrides", name, dstID) +} + +// TestDestinationUpsertPreservesDeliveryGroupOverrides is the regression guard +// for #393. The API replaces delivery_policy.groups wholesale, and the CLI +// requires --delivery-group-key and --delivery-group-rate-period whenever +// --delivery-group-rate is given, so "just bump the group rate" always sends a +// full groups object. Before the fix that silently destroyed the stored +// overrides. Only unit tests covered this; nothing exercised the real API. +func TestDestinationUpsertPreservesDeliveryGroupOverrides(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dg-upsert-" + generateTimestamp() + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + ) + require.NoError(t, err, "Should create destination carrying overrides") + + dstID, ok := created["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + require.Equal(t, sampleOverrides(), destinationDeliveryGroups(t, created)["overrides"], + "precondition: the destination must be created with overrides") + + // Change ONLY the group rate. The key and rate-period have to be repeated + // because the CLI refuses a partial group triple. + var upserted map[string]interface{} + err = cli.RunJSON(&upserted, "gateway", "destination", "upsert", name, + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "30", + "--delivery-group-rate-period", "second", + ) + require.NoError(t, err, "Should upsert the group rate") + + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "destination", "get", dstID) + require.NoError(t, err, "Should read the destination back after upsert") + + groups := destinationDeliveryGroups(t, fetched) + assert.Equal(t, float64(30), groups["rate"], "the requested rate change must apply") + assert.Equal(t, "body.customer_id", groups["key"], "the group key must be unchanged") + assert.Equal(t, "second", groups["rate_period"], "the group rate period must be unchanged") + assert.Equal(t, sampleOverrides(), groups["overrides"], + "bumping the group rate must not destroy the stored per-group overrides (#393)") +} + +// TestDestinationUpdatePreservesDeliveryGroupOverrides is the same #393 +// contract on `gateway destination update`. +// +// KNOWN FAILURE at the time of writing. preserveDeliveryGroupOverrides is +// called from `destination upsert` and `connection upsert`, but not from +// `destination update`, so bumping only the group rate through `update` still +// destroys the stored overrides — the identical silent data loss, on a sibling +// command. Verified against the live API: the groups object comes back with +// rate 30 and no overrides key at all. +// +// The behaviour a user gets should not depend on which of two commands they +// reach for, so this asserts the same thing the upsert test does. +func TestDestinationUpdatePreservesDeliveryGroupOverrides(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dg-update-" + generateTimestamp() + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + ) + require.NoError(t, err, "Should create destination carrying overrides") + + dstID, ok := created["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + require.Equal(t, sampleOverrides(), destinationDeliveryGroups(t, created)["overrides"], + "precondition: the destination must be created with overrides") + + // Change ONLY the group rate. + var updated map[string]interface{} + err = cli.RunJSON(&updated, "gateway", "destination", "update", dstID, + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "30", + "--delivery-group-rate-period", "second", + ) + require.NoError(t, err, "Should update the group rate") + + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "destination", "get", dstID) + require.NoError(t, err, "Should read the destination back after update") + + groups := destinationDeliveryGroups(t, fetched) + assert.Equal(t, float64(30), groups["rate"], "the requested rate change must apply") + assert.Equal(t, sampleOverrides(), groups["overrides"], + "bumping the group rate via `destination update` must not destroy the stored "+ + "per-group overrides; the #393 fix reached upsert but not update") +} + +// TestDestinationDeliveryPolicyRejectedForCLIType asserts that delivery-policy +// flags are refused on a CLI destination. A CLI destination carries no +// delivery_policy in the API schema: the API accepts the request and silently +// discards the policy, so the CLI has to refuse rather than let the flags look +// applied. Nothing may be created. +func TestDestinationDeliveryPolicyRejectedForCLIType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + }{ + {"rate limit", []string{"--rate-limit", "100", "--rate-limit-period", "minute"}}, + {"delivery group", []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + }}, + {"delivery group with overrides", []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-cli-" + generateTimestamp() + + args := append([]string{ + "gateway", "destination", "create", + "--name", name, + "--type", "CLI", + "--cli-path", "/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "delivery-policy flags must be refused for CLI destinations") + assert.Contains(t, stdout+stderr, "CLI destinations", + "the error should name CLI destinations as the reason") + + requireNoDestination(t, cli, name) + }) + } +} + +// TestDestinationDeliveryPolicyPartialFlagsRejected asserts that an incomplete +// set of delivery-policy flags is refused before any API call, and that nothing +// is created. Each of these silently dropped the value at some point. +func TestDestinationDeliveryPolicyPartialFlagsRejected(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + wantError string + }{ + { + name: "group rate without key", + flags: []string{"--delivery-group-rate", "10", "--delivery-group-rate-period", "second"}, + wantError: "--delivery-group-key", + }, + { + name: "group rate without rate period", + flags: []string{"--delivery-group-key", "body.customer_id", "--delivery-group-rate", "10"}, + wantError: "--delivery-group-rate-period", + }, + { + name: "group key without rate", + flags: []string{"--delivery-group-key", "body.customer_id", "--delivery-group-rate-period", "second"}, + wantError: "--delivery-group-rate", + }, + { + name: "overrides without a key", + flags: []string{"--delivery-group-overrides", sampleOverridesJSON}, + wantError: "--delivery-group-key", + }, + { + name: "rate limit without period", + flags: []string{"--rate-limit", "100"}, + wantError: "--rate-limit-period", + }, + { + name: "rate limit period without rate", + flags: []string{"--rate-limit-period", "minute"}, + wantError: "--rate-limit", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-partial-" + generateTimestamp() + + args := append([]string{ + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "an incomplete delivery-policy flag set must be refused") + assert.Contains(t, stdout+stderr, tc.wantError, + "the error should name the missing flag") + + requireNoDestination(t, cli, name) + }) + } +} + +// TestDestinationDeliveryPolicyRejectsNonPositiveRates asserts that zero and +// negative rates are refused client-side rather than dropped. Testing only +// `rate > 0` once let --rate-limit=-5 fall through both guards: the command +// succeeded having quietly ignored the value. +func TestDestinationDeliveryPolicyRejectsNonPositiveRates(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + wantError string + }{ + { + name: "negative rate limit", + flags: []string{"--rate-limit=-5", "--rate-limit-period", "minute"}, + wantError: "--rate-limit must be a positive integer", + }, + { + name: "zero rate limit", + flags: []string{"--rate-limit", "0", "--rate-limit-period", "minute"}, + wantError: "--rate-limit must be a positive integer", + }, + { + name: "negative group rate", + flags: []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate=-5", + "--delivery-group-rate-period", "second", + }, + wantError: "--delivery-group-rate must be a positive integer", + }, + { + name: "zero group rate", + flags: []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "0", + "--delivery-group-rate-period", "second", + }, + wantError: "--delivery-group-rate must be a positive integer", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-rate-" + generateTimestamp() + + args := append([]string{ + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "a non-positive rate must be refused, not silently dropped") + assert.Contains(t, stdout+stderr, tc.wantError) + + requireNoDestination(t, cli, name) + }) + } +} + +// TestDestinationDeliveryGroupOverridesMustBeJSONObject asserts that +// --delivery-group-overrides is parsed and rejected when it is not a JSON +// object. A JSON array parses as valid JSON but not as the object the API +// expects, so it needs its own case. +func TestDestinationDeliveryGroupOverridesMustBeJSONObject(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + overrides string + }{ + {"malformed JSON", `{"cust_1": {"rate": 5`}, + {"not JSON at all", `cust_1=5`}, + {"JSON array instead of object", `[{"rate":5,"rate_period":"minute"}]`}, + {"JSON null", `null`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-badjson-" + generateTimestamp() + + stdout, stderr, err := cli.Run( + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", tc.overrides, + ) + require.Error(t, err, "invalid overrides JSON must be refused") + assert.Contains(t, stdout+stderr, "--delivery-group-overrides must be a valid JSON object") + + requireNoDestination(t, cli, name) + }) + } +} diff --git a/test/acceptance/metrics_test.go b/test/acceptance/metrics_test.go index 456aebac..67bb3189 100644 --- a/test/acceptance/metrics_test.go +++ b/test/acceptance/metrics_test.go @@ -77,6 +77,21 @@ func TestMetricsEventsQueueDepth(t *testing.T) { assert.NotEmpty(t, stdout) } +// TestMetricsEventsQueueDepthMeasure covers the advertised spelling of the +// measure. `queue_depth` is what --help tells the user to pass, but the +// endpoint's enum accepts max_depth and max_age only, so the flag could never +// succeed until the CLI started translating it. TestMetricsEventsQueueDepth +// above passes max_depth, the wire spelling, so it never exercised the name +// the CLI documents. +func TestMetricsEventsQueueDepthMeasure(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "queue_depth")...) + assert.NotEmpty(t, stdout) +} + func TestMetricsEventsQueueDepthWithDimensions(t *testing.T) { if testing.Short() { t.Skip("Skipping acceptance test in short mode") @@ -97,6 +112,21 @@ func TestMetricsEventsPending(t *testing.T) { assert.NotEmpty(t, stdout) } +// TestMetricsEventsPendingWithoutGranularity covers --measures pending on its +// own. Routing to the pending-timeseries endpoint used to be gated on +// --granularity also being set; without it the call fell through to the default +// events route, which rejects the measure. Granularity is optional on that +// endpoint, so TestMetricsEventsPending above — which always passes +// --granularity 1h — could never have caught it. +func TestMetricsEventsPendingWithoutGranularity(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "pending")...) + assert.NotEmpty(t, stdout) +} + // --- Events (consolidated: events-by-issue routing) --- func TestMetricsEventsByIssueID(t *testing.T) { From d5836cd9e3ed503eda19e81413875cad90ebbc41 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 19:37:32 +0100 Subject: [PATCH 06/16] fix: resolve MCP active project name for project-scoped keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gateway MCP response carried meta.active_project_name: "" whenever the CLI was authenticated with a project-scoped credential (hookdeck ci keys, dashboard/single-project API keys). The profile on disk stores only project_id, so fillProjectDisplayNameIfNeeded has to recover the name from the API, and its only source was GET /projects — which 403s for those credentials. The error was swallowed, so the meta block an LLM client shows the user had no readable project name, including before the pause/unpause write actions. Fall back to /cli-auth/validate, which does return team_name_no_org and organization_name for those keys (this is where whoami gets them). Its names are only applied when the key's project matches the active project id, mirroring resolveActiveProject in whoami, so the meta block can never name the wrong project. Refs https://github.com/hookdeck/hookdeck-cli/issues/405 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/gateway/mcp/project_display.go | 32 +++++++- pkg/gateway/mcp/project_display_test.go | 104 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/pkg/gateway/mcp/project_display.go b/pkg/gateway/mcp/project_display.go index c16cffd8..9be49e92 100644 --- a/pkg/gateway/mcp/project_display.go +++ b/pkg/gateway/mcp/project_display.go @@ -7,7 +7,8 @@ import ( // fillProjectDisplayNameIfNeeded sets client.ProjectOrg and client.ProjectName from // ListProjects when the client has an API key and project id but no cached org/name -// (typical after loading profile from disk). Fails silently on API errors. +// (the profile on disk stores only project_id, so every process starts blank). +// Fails silently on API errors. // Stdio MCP invokes tools sequentially, so this is safe without locking. func fillProjectDisplayNameIfNeeded(client *hookdeck.Client) { if client == nil || client.APIKey == "" || client.ProjectID == "" { @@ -16,9 +17,18 @@ func fillProjectDisplayNameIfNeeded(client *hookdeck.Client) { if client.ProjectName != "" || client.ProjectOrg != "" { return } + if fillFromProjectList(client) { + return + } + fillFromValidate(client) +} + +// fillFromProjectList resolves the active project's org/name from GET /projects. +// Reports whether the active project was found. +func fillFromProjectList(client *hookdeck.Client) bool { projects, err := client.ListProjects() if err != nil { - return + return false } items := project.NormalizeProjects(projects, client.ProjectID) for i := range items { @@ -27,6 +37,24 @@ func fillProjectDisplayNameIfNeeded(client *hookdeck.Client) { } client.ProjectOrg = items[i].Org client.ProjectName = items[i].Project + return true + } + return false +} + +// fillFromValidate resolves the org/name from /cli-auth/validate, which reports the +// project the API key is bound to. Project-scoped credentials (hookdeck ci keys, +// dashboard API keys) cannot list projects at all, so this is the only source of a +// display name for them. The names are only applied when the key's project matches +// the active one — otherwise the meta block would name the wrong project. +func fillFromValidate(client *hookdeck.Client) { + response, err := client.ValidateAPIKey() + if err != nil || response == nil { + return + } + if response.ProjectID != client.ProjectID { return } + client.ProjectOrg = response.OrganizationName + client.ProjectName = response.ProjectName } diff --git a/pkg/gateway/mcp/project_display_test.go b/pkg/gateway/mcp/project_display_test.go index 5439d663..39f386c4 100644 --- a/pkg/gateway/mcp/project_display_test.go +++ b/pkg/gateway/mcp/project_display_test.go @@ -35,6 +35,110 @@ func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) { require.Equal(t, "production", client.ProjectName) } +// A project-scoped credential (hookdeck ci key, dashboard API key) cannot list +// projects, so the display name has to come from /cli-auth/validate instead. +func TestFillProjectDisplayNameIfNeeded_FallsBackToValidateWhenListForbidden(t *testing.T) { + var validateCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case hookdeck.APIPathPrefix + "/projects": + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "forbidden"}) + case hookdeck.APIPathPrefix + "/cli-auth/validate": + validateCalls++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "team_id": "proj_x", + "team_name_no_org": "Shopify Demo", + "team_name": "[Demos] Shopify Demo", + "organization_name": "Demos", + "team_type": "event_gateway", + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + client := &hookdeck.Client{ + BaseURL: u, + APIKey: "k", + ProjectID: "proj_x", + } + fillProjectDisplayNameIfNeeded(client) + require.Equal(t, 1, validateCalls) + require.Equal(t, "Shopify Demo", client.ProjectName) + require.Equal(t, "Demos", client.ProjectOrg) +} + +// The key's project can differ from the profile's active project. Naming the +// key's project in that case would mislabel the project the tools act on. +func TestFillProjectDisplayNameIfNeeded_IgnoresValidateForDifferentProject(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case hookdeck.APIPathPrefix + "/projects": + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "forbidden"}) + case hookdeck.APIPathPrefix + "/cli-auth/validate": + _ = json.NewEncoder(w).Encode(map[string]any{ + "team_id": "proj_other", + "team_name_no_org": "Other Project", + "organization_name": "Demos", + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + client := &hookdeck.Client{ + BaseURL: u, + APIKey: "k", + ProjectID: "proj_x", + } + fillProjectDisplayNameIfNeeded(client) + require.Equal(t, "", client.ProjectName) + require.Equal(t, "", client.ProjectOrg) +} + +// When the project list resolves the name, /cli-auth/validate must not be called. +func TestFillProjectDisplayNameIfNeeded_SkipsValidateWhenListResolves(t *testing.T) { + var validateCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case hookdeck.APIPathPrefix + "/projects": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"id": "proj_x", "name": "[Acme] production", "type": "console"}, + }) + case hookdeck.APIPathPrefix + "/cli-auth/validate": + validateCalls++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "team_id": "proj_x", + "team_name_no_org": "from-validate", + "organization_name": "from-validate-org", + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + client := &hookdeck.Client{ + BaseURL: u, + APIKey: "k", + ProjectID: "proj_x", + } + fillProjectDisplayNameIfNeeded(client) + require.Equal(t, 0, validateCalls) + require.Equal(t, "production", client.ProjectName) + require.Equal(t, "Acme", client.ProjectOrg) +} + func TestFillProjectDisplayNameIfNeeded_NoOpWhenNameSet(t *testing.T) { client := &hookdeck.Client{ProjectID: "p", ProjectName: "already"} fillProjectDisplayNameIfNeeded(client) From 280c0ace9ab20ab8ebeb385b93f5600c980b42f8 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 19:40:15 +0100 Subject: [PATCH 07/16] fix: stop hookdeck login opening a browser where nobody asked for one Two ways `hookdeck login` failed a caller with no terminal, both in pkg/login. #400: with an EMPTY config, `hookdeck login Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/login/client_login.go | 38 ++++- pkg/login/client_login_test.go | 209 ++++++++++++++++++++++++++++ pkg/login/interactive_login.go | 18 +++ pkg/login/interactive_login_test.go | 48 +++++++ 4 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 pkg/login/interactive_login_test.go diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 82e56d14..3df06472 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -37,6 +37,25 @@ var ErrRejectedKeyNoTerminal = errors.New( "or use hookdeck ci --api-key with a project API key", ) +// ErrNoCredentialsNoTerminal is returned when nothing is saved to sign in with +// and there is no terminal to complete browser sign-in with. It names the ways +// in that need no terminal, because the only other advice - "run it in a +// terminal" - is no help to the CI job, container or agent that hit this. +var ErrNoCredentialsNoTerminal = errors.New( + "no saved credentials, and browser sign-in needs an interactive terminal; " + + "use hookdeck ci --api-key with a project API key, " + + "hookdeck login --cli-key with a CLI key, " + + "or set HOOKDECK_API_KEY to a project API key", +) + +// browserSignInNeedsStdin reports whether waitForLoginSession would take the +// branch that prompts for Enter and opens a browser. Its other branch prints +// the URL and polls without reading stdin, which works headlessly and must not +// be blocked. +func browserSignInNeedsStdin() bool { + return !isSSH() && canOpenBrowser() +} + const guestUpgradePollInterval = 2 * time.Second const guestUpgradeMaxAttempts = 2 * 60 @@ -57,10 +76,8 @@ func Login(config *configpkg.Config, input io.Reader) error { return err } // Refuse only where the flow would have to read stdin, mirroring the - // branch in waitForLoginSession. Its other branch prints the URL and - // polls without stdin, which works headlessly and must not be blocked. - needsStdin := !isSSH() && canOpenBrowser() - if !stdinIsTerminal() && needsStdin { + // branch in waitForLoginSession. + if !stdinIsTerminal() && browserSignInNeedsStdin() { return ErrRejectedKeyNoTerminal } // Must clear the key first or we would re-enter this branch only. @@ -96,6 +113,15 @@ func Login(config *configpkg.Config, input io.Reader) error { } } + // Same guard, for the path that never had a key to reject. An empty config + // skipped the block above entirely and arrived here, where waitForLoginSession + // prompted for Enter, read EOF from /dev/null instantly, opened a browser + // window on somebody's desktop and then polled forever. Refuse before + // StartLogin so no session is created for a sign-in nobody can complete. + if !stdinIsTerminal() && browserSignInNeedsStdin() { + return ErrNoCredentialsNoTerminal + } + parsedBaseURL, err := url.Parse(config.APIBaseURL) if err != nil { return err @@ -122,7 +148,9 @@ func waitForLoginSession(config *configpkg.Config, input io.Reader, session *hoo s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) } else { - fmt.Printf("Press Enter to open the browser (^C to quit)") + // Reached only with a terminal on stdin (Login refuses otherwise), so + // there is someone to press Enter and a terminal to deliver ^C to. + fmt.Println("Press Enter to open the browser (^C to quit)") fmt.Fscanln(input) s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go index 924da9fa..b9872ffa 100644 --- a/pkg/login/client_login_test.go +++ b/pkg/login/client_login_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -456,3 +457,211 @@ api_key = "hk_test_stale_abcdefghij" "a URL-only sign-in needs no terminal and must not be refused") require.True(t, sawCLIAuthPost, "should have started the device flow") } + +// TestLogin_noCredentialsHeadlessFailsFast is the bug this guard exists for: an +// empty config skipped the saved-key block entirely, so nothing checked for a +// terminal before waitForLoginSession printed "Press Enter", read EOF from +// /dev/null, opened a real browser window and polled forever. +func TestLogin_noCredentialsHeadlessFailsFast(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + + // A machine that could open a browser, which is exactly what makes this + // dangerous: the window opens on somebody's desktop unasked. + oldCan := canOpenBrowser + canOpenBrowser = func() bool { return true } + t.Cleanup(func() { canOpenBrowser = oldCan }) + + for _, key := range []string{"SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"} { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } + + browserOpens := 0 + oldOpen := openBrowser + openBrowser = func(string) error { + browserOpens++ + return nil + } + t.Cleanup(func() { openBrowser = oldOpen }) + + var sawCLIAuthPost bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth") { + sawCLIAuthPost = true + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.DeviceName = "test-device" + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + require.Empty(t, cfg.Profile.APIKey, "the repro starts from an empty config") + + done := make(chan error, 1) + go func() { done <- Login(cfg, strings.NewReader("")) }() + + select { + case err = <-done: + case <-time.After(10 * time.Second): + t.Fatal("Login did not return; it is waiting for a confirmation nobody can give") + } + + require.ErrorIs(t, err, ErrNoCredentialsNoTerminal) + require.Equal(t, 0, browserOpens, "no browser window without a terminal to have asked for one") + require.False(t, sawCLIAuthPost, "browser sign-in must not be started without a terminal") + require.Contains(t, err.Error(), "hookdeck ci --api-key") + require.Contains(t, err.Error(), "--cli-key") + require.Contains(t, err.Error(), "HOOKDECK_API_KEY") +} + +// TestLogin_noCredentialsNoBrowserStillSignsIn: no key, no terminal, no browser. +// waitForLoginSession prints the URL and polls without reading stdin, so a human +// elsewhere can finish it. The guard must not take this branch out. +func TestLogin_noCredentialsNoBrowserStillSignsIn(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + + oldCan := canOpenBrowser + canOpenBrowser = func() bool { return false } + t.Cleanup(func() { canOpenBrowser = oldCan }) + + var sawCLIAuthPost bool + var serverURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth"): + sawCLIAuthPost = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "browser_url": "https://example.test/auth", + "poll_url": serverURL + hookdeck.APIPathPrefix + "/cli-auth/poll?key=k", + }) + case strings.Contains(r.URL.Path, "/cli-auth/poll"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "claimed": true, "key": "hk_test_newkey_abcdefghij", + "team_id": "tm_x", "team_type": "event_gateway", + "team_name": "P", "user_name": "U", "user_email": "u@example.test", + "organization_name": "O", "organization_id": "org_x", "client_id": "cl_x", + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + serverURL = ts.URL + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.DeviceName = "test-device" + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + require.NoError(t, Login(cfg, strings.NewReader("")), + "a URL-only sign-in needs no terminal and must not be refused") + require.True(t, sawCLIAuthPost, "should have started the device flow") + require.Equal(t, "hk_test_newkey_abcdefghij", cfg.Profile.APIKey) +} + +// TestLogin_noCredentialsWithTerminalOpensBrowser pins the unchanged path: with +// a terminal on stdin, Enter still opens the browser. +func TestLogin_noCredentialsWithTerminalOpensBrowser(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + + oldCan := canOpenBrowser + canOpenBrowser = func() bool { return true } + t.Cleanup(func() { canOpenBrowser = oldCan }) + + for _, key := range []string{"SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"} { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } + + var openedURL string + oldOpen := openBrowser + openBrowser = func(u string) error { + openedURL = u + return nil + } + t.Cleanup(func() { openBrowser = oldOpen }) + + var serverURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "browser_url": "https://example.test/auth", + "poll_url": serverURL + hookdeck.APIPathPrefix + "/cli-auth/poll?key=k", + }) + case strings.Contains(r.URL.Path, "/cli-auth/poll"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "claimed": true, "key": "hk_test_newkey_abcdefghij", + "team_id": "tm_x", "team_type": "event_gateway", + "team_name": "P", "user_name": "U", "user_email": "u@example.test", + "organization_name": "O", "organization_id": "org_x", "client_id": "cl_x", + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + serverURL = ts.URL + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.DeviceName = "test-device" + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + // Capture stdout: the prompt had no trailing newline, so it ran straight + // into the "Waiting for confirmation..." spinner on the same line. + stdoutFile, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + oldStdout := os.Stdout + os.Stdout = stdoutFile + t.Cleanup(func() { os.Stdout = oldStdout }) + + require.NoError(t, Login(cfg, strings.NewReader("\n"))) + + os.Stdout = oldStdout + require.NoError(t, stdoutFile.Close()) + out, err := os.ReadFile(stdoutFile.Name()) + require.NoError(t, err) + require.Contains(t, string(out), "Press Enter to open the browser (^C to quit)\n", + "the prompt must end its own line, not run into the spinner") + + require.Equal(t, "https://example.test/auth", openedURL, + "with a terminal the Enter-then-browser branch is unchanged") + require.Equal(t, "hk_test_newkey_abcdefghij", cfg.Profile.APIKey) +} diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index 3b3d266b..f463dad4 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -19,8 +19,26 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/validators" ) +// ErrInteractiveLoginNoTerminal is returned when hookdeck login -i is run with +// no terminal. The key prompt reads without echoing, which needs terminal +// control; without a terminal term.GetState fails and the raw termios error +// ("operation not supported by device") reached the caller, printed after a +// prompt they could never answer and saying nothing about what to do instead. +var ErrInteractiveLoginNoTerminal = errors.New( + "interactive sign-in needs an interactive terminal to read the key without echoing it; " + + "use hookdeck login --cli-key with a CLI key, " + + "hookdeck ci --api-key with a project API key, " + + "or set HOOKDECK_API_KEY to a project API key", +) + // InteractiveLogin lets the user set configuration on the command line func InteractiveLogin(config *configpkg.Config) error { + // Refuse before printing the prompt: securePrompt reads os.Stdin with echo + // disabled, so there is nothing this flow can do without a terminal. + if !stdinIsTerminal() { + return ErrInteractiveLoginNoTerminal + } + apiKey, err := getConfigureAPIKey(os.Stdin) if err != nil { return err diff --git a/pkg/login/interactive_login_test.go b/pkg/login/interactive_login_test.go new file mode 100644 index 00000000..14742a2d --- /dev/null +++ b/pkg/login/interactive_login_test.go @@ -0,0 +1,48 @@ +package login + +import ( + "os" + "testing" + + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/stretchr/testify/require" +) + +// TestInteractiveLogin_noTerminalExplainsItself covers #401: `hookdeck login -i` +// with stdin at /dev/null printed "Enter your CLI API key: " and then failed with +// "operation not supported by device" - the termios error from term.GetState, +// surfaced verbatim. It exited fast, so only the message was wrong. +func TestInteractiveLogin_noTerminalExplainsItself(t *testing.T) { + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + + stdoutFile, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + oldStdout := os.Stdout + os.Stdout = stdoutFile + t.Cleanup(func() { os.Stdout = oldStdout }) + + cfg := &configpkg.Config{ + APIBaseURL: "https://api.example.test", + DeviceName: "test-device", + LogLevel: "error", + TelemetryDisabled: true, + } + cfg.Profile = configpkg.Profile{Name: "default", Config: cfg} + + err = InteractiveLogin(cfg) + + os.Stdout = oldStdout + require.NoError(t, stdoutFile.Close()) + out, readErr := os.ReadFile(stdoutFile.Name()) + require.NoError(t, readErr) + + require.ErrorIs(t, err, ErrInteractiveLoginNoTerminal) + require.NotContains(t, err.Error(), "not supported by device", + "the raw termios error tells the user nothing") + require.Contains(t, err.Error(), "--cli-key") + require.Contains(t, err.Error(), "hookdeck ci --api-key") + require.NotContains(t, string(out), "Enter your CLI API key", + "do not prompt for something that cannot be typed") +} From 81e1f322a026103a298fc65898ecd9728e03e9cb Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 20:55:29 +0100 Subject: [PATCH 08/16] fix: guard the guest-upgrade browser branch, and always print the sign-in URL #373. The third copy of the Enter-then-browser branch, in waitForGuestUpgrade. A guest profile whose key still validates skipped every guard already added, so `hookdeck login Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/login/client_login.go | 51 ++++-- pkg/login/client_login_test.go | 286 ++++++++++++++++++++++++++++++++- 2 files changed, 316 insertions(+), 21 deletions(-) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 3df06472..821d3ada 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -48,6 +48,16 @@ var ErrNoCredentialsNoTerminal = errors.New( "or set HOOKDECK_API_KEY to a project API key", ) +// ErrGuestUpgradeNoTerminal is returned when a guest profile's browser sign-up +// would have to read stdin and there is no terminal. Unlike the other two this +// one has no headless equivalent - a permanent account is created in the +// browser - so it names signing in with an account that already exists. +var ErrGuestUpgradeNoTerminal = errors.New( + "creating a permanent account needs browser sign-up, and browser sign-up needs an interactive terminal; " + + "run hookdeck login in a terminal to keep this sandbox's data, " + + "or sign in to an account you already have with hookdeck ci --api-key or hookdeck login --cli-key", +) + // browserSignInNeedsStdin reports whether waitForLoginSession would take the // branch that prompts for Enter and opens a browser. Its other branch prints // the URL and polls without reading stdin, which works headlessly and must not @@ -153,14 +163,18 @@ func waitForLoginSession(config *configpkg.Config, input io.Reader, session *hoo fmt.Println("Press Enter to open the browser (^C to quit)") fmt.Fscanln(input) - s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) + // Print the URL whether or not the browser opens (#373). open.Browser + // is exec.Command(...).Start(), which returns nil the moment the child + // is spawned, so a browser that dies straight after - WSL, containers, + // VS Code Remote - reports success and leaves the user with a spinner + // and no link. The error branch below is the detectable half only. + fmt.Printf("To authenticate with Hookdeck, please go to: %s\n", session.BrowserURL) - err := openBrowser(session.BrowserURL) - if err != nil { - msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", session.BrowserURL) - ansi.StopSpinner(s, msg, os.Stdout) - s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) + if err := openBrowser(session.BrowserURL); err != nil { + fmt.Println("Could not open the browser for you; use the link above.") } + + s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout) } response, err := session.WaitForAPIKey(0, 0) @@ -285,6 +299,14 @@ func isSSH() bool { } func waitForGuestUpgrade(config *configpkg.Config, input io.Reader) error { + // The third copy of the same branch (#408). Refuse before + // RefreshGuestSigninLink mints a link for a sign-up nobody can complete: + // without this, a guest profile with a still-valid key opened a browser + // window unasked and then polled for four minutes. + if !stdinIsTerminal() && browserSignInNeedsStdin() { + return ErrGuestUpgradeNoTerminal + } + guestURL := RefreshGuestSigninLink(config) if guestURL == "" { return fmt.Errorf("unable to create guest sign-up link") @@ -295,17 +317,20 @@ func waitForGuestUpgrade(config *configpkg.Config, input io.Reader) error { fmt.Printf("To create a permanent Hookdeck account, please go to: %s\n", guestURL) s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) } else { - fmt.Printf("Press Enter to open the browser (^C to quit)") + // Reached only with a terminal on stdin (guarded above), so there is + // someone to press Enter and a terminal to deliver ^C to. + fmt.Println("Press Enter to open the browser (^C to quit)") fmt.Fscanln(input) - s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) + // Printed whether or not the browser opens, for the reason given in + // waitForLoginSession (#373). + fmt.Printf("To create a permanent Hookdeck account, please go to: %s\n", guestURL) - err := openBrowser(guestURL) - if err != nil { - msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", guestURL) - ansi.StopSpinner(s, msg, os.Stdout) - s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) + if err := openBrowser(guestURL); err != nil { + fmt.Println("Could not open the browser for you; use the link above.") } + + s = ansi.StartNewSpinner("Waiting for account creation...", os.Stdout) } response, err := waitForGuestUpgradeCompletion(config) diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go index b9872ffa..91eda2a9 100644 --- a/pkg/login/client_login_test.go +++ b/pkg/login/client_login_test.go @@ -2,6 +2,7 @@ package login import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -135,6 +136,13 @@ func TestLogin_guestProfileWithValidKeyStartsGuestUpgrade(t *testing.T) { configpkg.ResetAPIClientForTesting() t.Cleanup(configpkg.ResetAPIClientForTesting) + // Stated explicitly rather than inherited from however the test binary was + // started: no terminal and no browser is the URL-and-poll branch, which + // reads no stdin and must stay open to a human elsewhere. + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + oldCan := canOpenBrowser oldOpen := openBrowser canOpenBrowser = func() bool { return false } @@ -476,10 +484,7 @@ func TestLogin_noCredentialsHeadlessFailsFast(t *testing.T) { canOpenBrowser = func() bool { return true } t.Cleanup(func() { canOpenBrowser = oldCan }) - for _, key := range []string{"SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"} { - t.Setenv(key, "") - require.NoError(t, os.Unsetenv(key)) - } + clearSSHEnv(t) browserOpens := 0 oldOpen := openBrowser @@ -597,10 +602,7 @@ func TestLogin_noCredentialsWithTerminalOpensBrowser(t *testing.T) { canOpenBrowser = func() bool { return true } t.Cleanup(func() { canOpenBrowser = oldCan }) - for _, key := range []string{"SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"} { - t.Setenv(key, "") - require.NoError(t, os.Unsetenv(key)) - } + clearSSHEnv(t) var openedURL string oldOpen := openBrowser @@ -660,8 +662,276 @@ func TestLogin_noCredentialsWithTerminalOpensBrowser(t *testing.T) { require.NoError(t, err) require.Contains(t, string(out), "Press Enter to open the browser (^C to quit)\n", "the prompt must end its own line, not run into the spinner") + // #373: openBrowser returned nil here. That is exactly the case the old code + // printed nothing for, and exactly the case that strands a WSL or container + // user with a spinner and no link, because Start() succeeding says only that + // a child process was spawned. + require.Contains(t, string(out), "To authenticate with Hookdeck, please go to: https://example.test/auth\n", + "the URL must be printed before the spinner even when the browser opened") require.Equal(t, "https://example.test/auth", openedURL, "with a terminal the Enter-then-browser branch is unchanged") require.Equal(t, "hk_test_newkey_abcdefghij", cfg.Profile.APIKey) } + +// clearSSHEnv makes isSSH() false, so browserSignInNeedsStdin() turns on whatever +// canOpenBrowser reports. Without it a run under SSH takes the URL-and-poll +// branch and the test silently stops testing anything. +func clearSSHEnv(t *testing.T) { + t.Helper() + for _, key := range []string{"SSH_TTY", "SSH_CONNECTION", "SSH_CLIENT"} { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } +} + +// TestLogin_guestUpgradeHeadlessFailsFast covers #408: the third copy of the +// Enter-then-browser branch. A guest profile whose key still validates went +// straight into it, so `hookdeck login Date: Mon, 14 Sep 2026 20:58:41 +0100 Subject: [PATCH 09/16] fix: stop update dropping --url, and stop queue depth shadowing issue_id Two commands reported success while answering a different question. #406: destination update/upsert build their config by switching on the destination type, and --type is normally omitted, so the switch fell to the empty-type default and --url and --cli-path were never copied into the request. The PUT went out without them and the command exited 0. PR #392 resolved the stored type for the delivery-policy guard but deliberately kept it out of config building, because wiring it there opportunistically would have made --url work only when a rate-limit flag happened to be present too. So resolve it generally instead: the lookup now fires for any flag whose handling depends on the type, and the resolved type is what config building gets. It reuses the memoised GetDestination the policy guard already pays for, so a typeless update still costs one GET. A type-specific flag the type has no field for is now refused rather than dropped, in both directions: --url on a stored CLI destination, and --url with no type to resolve at all (an upsert that is really a create). The empty-type default stays tolerant, because auth and delivery-policy flags mean the same thing whatever the type. #407: metrics events picks one endpoint from ordered conditions, so a queue-depth measure matched first and the issue_id dimension went to /metrics/queue-depth, which does not group by issue. "pending" shadowed it the same way, and the --issue-id filter was reported as an unsupported filter rather than as the second route it is. RejectCrossRouteEventQuery extends the #392 mixed-measure rule to measure/dimension conflicts and names both routes. It lives in pkg/hookdeck beside RejectMixedMeasureRoutes, which it subsumes; the MCP layer still calls the narrower one and needs the same one-line swap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- REFERENCE.md | 2 + pkg/cmd/destination_common.go | 120 +++++++++++-- pkg/cmd/destination_create.go | 7 +- pkg/cmd/destination_stored_state_test.go | 218 +++++++++++++++++++++-- pkg/cmd/destination_update.go | 21 ++- pkg/cmd/destination_upsert.go | 43 +++-- pkg/cmd/metrics_events.go | 16 +- pkg/cmd/metrics_events_routing_test.go | 99 ++++++++++ pkg/hookdeck/metrics_filters.go | 70 ++++++++ 9 files changed, 537 insertions(+), 59 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 8692f85a..39c44808 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1951,6 +1951,8 @@ Passing one where it does not apply is an `unknown flag` error rather than a sil `metrics events` routes to a different endpoint depending on `--measures` and `--dimensions`, so some of its filters are rejected for a given query — `--delivery-group` and `--status` cannot be combined with `--measures pending`, for example. The error names the flag and the route. +Only one endpoint answers a query, so a request cannot ask for two of them at once. `queue_depth`, `max_depth` and `max_age` select queue-depth metrics and `pending` selects pending metrics; neither can be combined with per-issue metrics (`--dimensions issue_id` or `--issue-id`), and measures belonging to two routes cannot be mixed in one `--measures`. Each combination is refused by name rather than answered from whichever route happened to match first. + ## Utilities diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index 6ea09a98..54181454 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -281,26 +281,112 @@ func (f *destinationConfigFlags) hasAnyDeliveryPolicyFlag() bool { f.DeliveryGroupRate != 0 || f.DeliveryGroupRatePeriod != "" || f.DeliveryGroupOverrides != "" } -// destinationTypeForPolicyCheck resolves the type that rejectDeliveryPolicyForCLI -// has to be applied to. +// typeSpecificDestinationFlags are the flags whose meaning depends on the +// destination type: buildDestinationConfigFromIndividualFlags reads each one +// only under the type whose config actually has that field. A value given for +// any other type is dropped from the request and the command still reports +// success, which is how --url went missing on a typeless update (#406). +var typeSpecificDestinationFlags = []struct { + name string + appliesTo string + given func(*destinationConfigFlags) bool +}{ + {"url", "HTTP", func(f *destinationConfigFlags) bool { return f.URL != "" }}, + {"http-method", "HTTP", func(f *destinationConfigFlags) bool { return f.HTTPMethod != "" }}, + {"path-forwarding-disabled", "HTTP", func(f *destinationConfigFlags) bool { return f.PathForwardingDisabled != nil }}, + {"cli-path", "CLI", func(f *destinationConfigFlags) bool { return f.CliPath != "" }}, +} + +// hasAnyTypeSpecificFlag reports whether a flag was given that only one +// destination type has a field for. +func (f *destinationConfigFlags) hasAnyTypeSpecificFlag() bool { + if f == nil { + return false + } + for _, flag := range typeSpecificDestinationFlags { + if flag.given(f) { + return true + } + } + return false +} + +// needsResolvedType reports whether any given flag is one whose handling depends +// on the destination type, and so whether resolving the stored type is worth an +// API call. Both kinds count: the delivery-policy flags, which are refused on a +// CLI destination, and the type-specific flags, which are only read under their +// own type. +func (f *destinationConfigFlags) needsResolvedType() bool { + return f.hasAnyDeliveryPolicyFlag() || f.hasAnyTypeSpecificFlag() +} + +// destinationTypeIsKnown reports whether this is a type the CLI builds config +// for. An unknown non-empty type is reported by the config builder itself, +// which names the supported set. +func destinationTypeIsKnown(destType string) bool { + switch strings.ToUpper(destType) { + case "HTTP", "CLI", "MOCK_API": + return true + } + return false +} + +// rejectTypeSpecificFlagsForOtherTypes refuses a type-specific flag that the +// type in hand has no field for, including the case where the type is not known +// at all. Silence was the old behaviour in both directions: the value was left +// out of the request body and the command exited 0 (#406). +func rejectTypeSpecificFlagsForOtherTypes(destType string, f *destinationConfigFlags) error { + if f == nil { + return nil + } + t := strings.ToUpper(destType) + if t != "" && !destinationTypeIsKnown(t) { + return nil + } + for _, flag := range typeSpecificDestinationFlags { + if !flag.given(f) { + continue + } + if t == "" { + // Only reachable when there is no stored destination to resolve the + // type from — an upsert that is really a create. + return fmt.Errorf("--%s cannot be applied without a destination type: pass --type (HTTP, CLI, MOCK_API)", flag.name) + } + if t != flag.appliesTo { + return fmt.Errorf("--%s applies to %s destinations, and this destination is %s; the API would drop it", flag.name, flag.appliesTo, t) + } + } + return nil +} + +// resolveDestinationType resolves the destination type that the config flags +// have to be interpreted against. +// +// `destination update` and `destination upsert` normally omit --type, and two +// separate things went wrong because of it. The delivery-policy guard compared +// against "" and passed, so rate-limit and delivery-group flags reached a stored +// CLI destination where the API accepts the request and discards the policy +// (#392). And config building switches on the type, so --url and --cli-path +// were never copied into the request body at all, and the command still exited +// 0 (#406). // -// `destination update` and `destination upsert` normally omit --type, so the -// flag alone is "" and the guard passed: rate-limit and delivery-group flags -// then reached a stored CLI destination, where the API accepts the request and -// discards the policy. Resolving the stored type is preferred over refusing the -// command, because the type the user omitted is already knowable and every one -// of these commands is addressing a destination the API can name. +// Resolving the stored type is preferred over refusing the command, because the +// type the user omitted is already knowable and every one of these commands is +// addressing a destination the API can name. It is deliberately not conditioned +// on which kind of flag was given: resolving only for the policy flags would +// have made --url start working when a rate-limit flag happened to be present +// too, which is a worse contract than failing uniformly. // // lookup returns the stored destination, or (nil, nil) when there is none — // an upsert that is really a create, where a typeless request is the API's to // reject. It is only called when the answer can change the outcome. -func destinationTypeForPolicyCheck(declaredType string, usesConfigJSON bool, flags *destinationConfigFlags, lookup func() (*hookdeck.Destination, error)) (string, error) { - if declaredType != "" || usesConfigJSON || !flags.hasAnyDeliveryPolicyFlag() { +func resolveDestinationType(declaredType string, usesConfigJSON bool, flags *destinationConfigFlags, lookup func() (*hookdeck.Destination, error)) (string, error) { + if declaredType != "" || usesConfigJSON || !flags.needsResolvedType() { return declaredType, nil } existing, err := lookup() if err != nil { - return "", fmt.Errorf("failed to look up the destination to check whether --rate-limit and --delivery-group-* apply to it: %w", err) + return "", fmt.Errorf("failed to look up the destination to resolve the type its config flags apply to: %w", err) } if existing == nil { return declaredType, nil @@ -326,7 +412,7 @@ func fetchDestinationByName(ctx context.Context, client *hookdeck.Client, name s // CLI destinations carry no delivery_policy in the API schema: the request is // accepted and the policy discarded, so without this the flags look applied and // never take effect. destType must be the resolved type, not the raw --type flag -// — see destinationTypeForPolicyCheck. +// — see resolveDestinationType. func rejectDeliveryPolicyForCLI(destType string, policy map[string]interface{}, flagPrefix string) error { if len(policy) == 0 || strings.ToUpper(destType) != "CLI" { return nil @@ -451,6 +537,12 @@ func buildDestinationConfigFromIndividualFlags(destType string, f *destinationCo } mergeDeliveryPolicy(config, policy) + // A flag belonging to another type would otherwise be dropped by the switch + // below without a word. + if err := rejectTypeSpecificFlagsForOtherTypes(destType, f); err != nil { + return nil, err + } + switch strings.ToUpper(destType) { case "HTTP": if f.URL != "" { @@ -474,6 +566,10 @@ func buildDestinationConfigFromIndividualFlags(destType string, f *destinationCo case "MOCK_API": // no extra fields default: + // An empty type stays tolerated here, because auth and delivery-policy + // flags mean the same thing whatever the type and a typeless build is a + // legitimate request for them. What cannot be tolerated is a type- + // specific flag with no type to apply it to, and that is refused above. if destType != "" { return nil, fmt.Errorf("unsupported destination type: %s (supported: HTTP, CLI, MOCK_API)", destType) } diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index f2d72490..c90897ab 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -103,9 +103,12 @@ func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args client := Config.GetAPIClient() ctx := context.Background() - // Sync url/cliPath into flags for buildDestinationConfigFromIndividualFlags when not using --config + // Sync url/cliPath into flags for buildDestinationConfigFromIndividualFlags + // when not using --config. --cli-path carries a "/" default on create, so it + // goes through cliPathFromFlags: an unset flag must not read as a path the + // user asked for, or every HTTP create would look like it named one. dc.destinationConfigFlags.URL = dc.url - dc.destinationConfigFlags.CliPath = dc.cliPath + dc.destinationConfigFlags.CliPath = cliPathFromFlags(cmd, dc.cliPath) config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) if err != nil { diff --git a/pkg/cmd/destination_stored_state_test.go b/pkg/cmd/destination_stored_state_test.go index 709dacb5..f13aef88 100644 --- a/pkg/cmd/destination_stored_state_test.go +++ b/pkg/cmd/destination_stored_state_test.go @@ -9,6 +9,8 @@ import ( "net/url" "testing" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,23 +46,23 @@ func destinationAPI(t *testing.T, stored *hookdeck.Destination, status int) *hoo return &hookdeck.Client{BaseURL: baseURL, APIKey: "k"} } -// TestDestinationTypeForPolicyCheck pins the guard that `destination update` and -// `destination upsert` were missing: both normally omit --type, so comparing -// rate-limit and delivery-group flags against the flag alone always passed and -// the flags reached a stored CLI destination, where the API discards them. -func TestDestinationTypeForPolicyCheck(t *testing.T) { +// TestResolveDestinationType pins the resolution both `destination update` and +// `destination upsert` depend on: both normally omit --type, so every flag whose +// handling turns on the type was being read against "" — the delivery-policy +// guard always passed, and --url and --cli-path were dropped from the request. +func TestResolveDestinationType(t *testing.T) { cli := &hookdeck.Destination{ID: "des_1", Name: "local", Type: "CLI"} policyFlags := &destinationConfigFlags{RateLimit: 100, RateLimitPeriod: "minute"} lookup := func() (*hookdeck.Destination, error) { return cli, nil } t.Run("omitted --type resolves to the stored type", func(t *testing.T) { - got, err := destinationTypeForPolicyCheck("", false, policyFlags, lookup) + got, err := resolveDestinationType("", false, policyFlags, lookup) require.NoError(t, err) assert.Equal(t, "CLI", got) }) t.Run("an explicit --type is trusted and no lookup happens", func(t *testing.T) { - got, err := destinationTypeForPolicyCheck("HTTP", false, policyFlags, func() (*hookdeck.Destination, error) { + got, err := resolveDestinationType("HTTP", false, policyFlags, func() (*hookdeck.Destination, error) { t.Fatal("must not spend an API call when --type was given") return nil, nil }) @@ -68,9 +70,23 @@ func TestDestinationTypeForPolicyCheck(t *testing.T) { assert.Equal(t, "HTTP", got) }) - t.Run("no policy flags means no lookup", func(t *testing.T) { - got, err := destinationTypeForPolicyCheck("", false, &destinationConfigFlags{URL: "https://x"}, func() (*hookdeck.Destination, error) { - t.Fatal("must not spend an API call when no policy flag is set") + t.Run("a type-specific flag resolves the type too, not just a policy flag", func(t *testing.T) { + // #406: this is the lookup that did not happen, so --url was built + // against "" and never reached the request. + got, err := resolveDestinationType("", false, &destinationConfigFlags{URL: "https://x"}, lookup) + require.NoError(t, err) + assert.Equal(t, "CLI", got) + }) + + t.Run("--cli-path resolves the type as well", func(t *testing.T) { + got, err := resolveDestinationType("", false, &destinationConfigFlags{CliPath: "/hooks"}, lookup) + require.NoError(t, err) + assert.Equal(t, "CLI", got) + }) + + t.Run("no type-dependent flag means no lookup", func(t *testing.T) { + got, err := resolveDestinationType("", false, &destinationConfigFlags{AuthMethod: "bearer", BearerToken: "t"}, func() (*hookdeck.Destination, error) { + t.Fatal("auth means the same thing whatever the type; do not spend an API call on it") return nil, nil }) require.NoError(t, err) @@ -78,7 +94,7 @@ func TestDestinationTypeForPolicyCheck(t *testing.T) { }) t.Run("--config takes precedence so the individual flags are ignored", func(t *testing.T) { - got, err := destinationTypeForPolicyCheck("", true, policyFlags, func() (*hookdeck.Destination, error) { + got, err := resolveDestinationType("", true, policyFlags, func() (*hookdeck.Destination, error) { t.Fatal("must not spend an API call when --config wins anyway") return nil, nil }) @@ -87,7 +103,7 @@ func TestDestinationTypeForPolicyCheck(t *testing.T) { }) t.Run("no stored destination leaves the type to the API", func(t *testing.T) { - got, err := destinationTypeForPolicyCheck("", false, policyFlags, func() (*hookdeck.Destination, error) { + got, err := resolveDestinationType("", false, policyFlags, func() (*hookdeck.Destination, error) { return nil, nil }) require.NoError(t, err) @@ -95,7 +111,7 @@ func TestDestinationTypeForPolicyCheck(t *testing.T) { }) t.Run("a failed lookup is an error, not a pass", func(t *testing.T) { - _, err := destinationTypeForPolicyCheck("", false, policyFlags, func() (*hookdeck.Destination, error) { + _, err := resolveDestinationType("", false, policyFlags, func() (*hookdeck.Destination, error) { return nil, errors.New("network down") }) require.Error(t, err) @@ -115,7 +131,7 @@ func TestUpsertRejectsDeliveryPolicyOnStoredCLIDestination(t *testing.T) { require.NoError(t, err) require.Contains(t, config, "delivery_policy", "the flags do build a policy; the type is what makes it useless") - policyType, err := destinationTypeForPolicyCheck("", false, flags, func() (*hookdeck.Destination, error) { + policyType, err := resolveDestinationType("", false, flags, func() (*hookdeck.Destination, error) { return fetchDestinationByName(context.Background(), client, "local") }) require.NoError(t, err) @@ -133,7 +149,7 @@ func TestUpsertAcceptsDeliveryPolicyOnStoredHTTPDestination(t *testing.T) { config, err := buildDestinationConfigFromFlags("", "", "", flags) require.NoError(t, err) - policyType, err := destinationTypeForPolicyCheck("", false, flags, func() (*hookdeck.Destination, error) { + policyType, err := resolveDestinationType("", false, flags, func() (*hookdeck.Destination, error) { return fetchDestinationByName(context.Background(), client, "web") }) require.NoError(t, err) @@ -384,7 +400,7 @@ func TestDestinationUpdateSharesOneLookup(t *testing.T) { config, err := buildDestinationConfigFromFlags("", "", "", flags) require.NoError(t, err) - policyType, err := destinationTypeForPolicyCheck("", false, flags, memoised) + policyType, err := resolveDestinationType("", false, flags, memoised) require.NoError(t, err) require.NoError(t, rejectDeliveryPolicyInConfigForCLI(policyType, config, "")) require.NoError(t, preserveStoredDeliveryGroupOverrides("des_1", config, memoised)) @@ -563,3 +579,173 @@ func TestDestinationUpsertBuildsRequestPreservingOverrides(t *testing.T) { assert.Equal(t, "https://api.example.com", req.Config["url"]) }) } + +// storedCLIDest is a stored CLI destination, the counterpart to +// storedHTTPDestWithOverrides for the type-specific flag cases. +func storedCLIDest() *hookdeck.Destination { + return &hookdeck.Destination{ + ID: "des_2", Name: "local", Type: "CLI", + Config: map[string]interface{}{"path": "/old"}, + } +} + +// TestDestinationUpdateAppliesTypeSpecificFlagsWithoutType pins #406. Config +// building switches on the destination type, so with --type omitted the switch +// fell through to the empty-type default and --url was never copied into the +// request at all: the PUT went out without it and the command exited 0. +func TestDestinationUpdateAppliesTypeSpecificFlagsWithoutType(t *testing.T) { + t.Run("--url alone reaches the request", func(t *testing.T) { + client, calls := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpdateCmd{url: "https://new.example.com/hook"} + + req, err := dc.buildUpdateRequest(context.Background(), client, "des_1") + require.NoError(t, err) + + assert.Equal(t, "https://new.example.com/hook", req.Config["url"], + "the URL the user asked for must be in the request body") + assert.Equal(t, "", req.Type, "resolving the stored type must not start sending a type the user did not pass") + assert.Equal(t, 1, *calls, "one GET, shared with every other guard that needs the stored record") + }) + + t.Run("--cli-path alone reaches the request", func(t *testing.T) { + client, _ := storedDestServer(t, storedCLIDest(), 0) + dc := &destinationUpdateCmd{cliPath: "/webhooks"} + + req, err := dc.buildUpdateRequest(context.Background(), client, "des_2") + require.NoError(t, err) + assert.Equal(t, "/webhooks", req.Config["path"]) + }) + + t.Run("resolution does not depend on a policy flag being present too", func(t *testing.T) { + // The point of #406: --url must not work only in the company of a flag + // that happens to trigger the lookup for its own reasons. + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + withPolicy := &destinationUpdateCmd{ + url: "https://new.example.com/hook", + destinationConfigFlags: destinationConfigFlags{RateLimit: 10, RateLimitPeriod: "minute"}, + } + reqWith, err := withPolicy.buildUpdateRequest(context.Background(), client, "des_1") + require.NoError(t, err) + + clientAlone, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + alone := &destinationUpdateCmd{url: "https://new.example.com/hook"} + reqAlone, err := alone.buildUpdateRequest(context.Background(), clientAlone, "des_1") + require.NoError(t, err) + + assert.Equal(t, reqWith.Config["url"], reqAlone.Config["url"], + "the URL must be applied the same way with and without a rate-limit flag") + }) + + t.Run("an explicit --type still spends no lookup", func(t *testing.T) { + client, calls := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpdateCmd{destType: "HTTP", url: "https://new.example.com/hook"} + + req, err := dc.buildUpdateRequest(context.Background(), client, "des_1") + require.NoError(t, err) + assert.Equal(t, "https://new.example.com/hook", req.Config["url"]) + assert.Equal(t, 0, *calls) + }) + + t.Run("a failed lookup refuses rather than dropping the URL", func(t *testing.T) { + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 1) + dc := &destinationUpdateCmd{url: "https://new.example.com/hook"} + + _, err := dc.buildUpdateRequest(context.Background(), client, "des_1") + require.Error(t, err, "sending the PUT without the URL would report success for an update that did not happen") + }) + + t.Run("--url on a stored CLI destination is refused, not dropped", func(t *testing.T) { + client, _ := storedDestServer(t, storedCLIDest(), 0) + dc := &destinationUpdateCmd{url: "https://new.example.com/hook"} + + _, err := dc.buildUpdateRequest(context.Background(), client, "des_2") + require.Error(t, err, "a CLI destination has no url field, so the value would vanish") + assert.Contains(t, err.Error(), "--url") + assert.Contains(t, err.Error(), "CLI") + }) +} + +// TestDestinationUpsertAppliesTypeSpecificFlagsWithoutType is the upsert half. +// It was worse there: the empty config was replaced by the stored one, so the +// PUT re-sent the destination exactly as it already was. +func TestDestinationUpsertAppliesTypeSpecificFlagsWithoutType(t *testing.T) { + t.Run("--url alone reaches the request", func(t *testing.T) { + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpsertCmd{name: "web", url: "https://new.example.com/hook"} + + req, err := dc.buildUpsertRequest(context.Background(), client) + require.NoError(t, err) + assert.Equal(t, "https://new.example.com/hook", req.Config["url"]) + assert.Equal(t, "HTTP", req.Type, "a PUT carrying a config has to say which kind of destination it is") + }) + + t.Run("--cli-path alone reaches the request", func(t *testing.T) { + client, _ := storedDestServer(t, storedCLIDest(), 0) + dc := &destinationUpsertCmd{name: "local", cliPath: "/webhooks"} + + req, err := dc.buildUpsertRequest(context.Background(), client) + require.NoError(t, err) + assert.Equal(t, "/webhooks", req.Config["path"]) + assert.Equal(t, "CLI", req.Type) + }) + + t.Run("a create with no stored type to resolve says so", func(t *testing.T) { + client, _ := storedDestServer(t, nil, 0) + dc := &destinationUpsertCmd{name: "brand-new", url: "https://new.example.com/hook"} + + _, err := dc.buildUpsertRequest(context.Background(), client) + require.Error(t, err, "there is no stored destination to read the type from, and the flag cannot be applied blind") + assert.Contains(t, err.Error(), "--type") + }) +} + +// TestRejectTypeSpecificFlagsForOtherTypes covers the guard directly, including +// the types it must leave alone. +func TestRejectTypeSpecificFlagsForOtherTypes(t *testing.T) { + t.Run("--cli-path on an HTTP destination", func(t *testing.T) { + err := rejectTypeSpecificFlagsForOtherTypes("HTTP", &destinationConfigFlags{CliPath: "/hooks"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--cli-path") + }) + + t.Run("--http-method on a MOCK_API destination", func(t *testing.T) { + err := rejectTypeSpecificFlagsForOtherTypes("MOCK_API", &destinationConfigFlags{HTTPMethod: "POST"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--http-method") + }) + + t.Run("flags that match the type pass", func(t *testing.T) { + assert.NoError(t, rejectTypeSpecificFlagsForOtherTypes("HTTP", &destinationConfigFlags{ + URL: "https://x", HTTPMethod: "POST", + })) + assert.NoError(t, rejectTypeSpecificFlagsForOtherTypes("CLI", &destinationConfigFlags{CliPath: "/hooks"})) + }) + + t.Run("type-independent flags are never type-checked", func(t *testing.T) { + assert.NoError(t, rejectTypeSpecificFlagsForOtherTypes("", &destinationConfigFlags{ + AuthMethod: "bearer", BearerToken: "t", RateLimit: 5, RateLimitPeriod: "minute", + })) + }) + + t.Run("an unknown type is left to the config builder to name", func(t *testing.T) { + assert.NoError(t, rejectTypeSpecificFlagsForOtherTypes("FOO", &destinationConfigFlags{URL: "https://x"})) + _, err := buildDestinationConfigFromIndividualFlags("FOO", &destinationConfigFlags{URL: "https://x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported destination type") + }) +} + +// TestCreateStillAcceptsAnUnsetCLIPath guards the interaction with --cli-path's +// "/" default on create: an unset flag must not read as a path the user asked +// for, or every HTTP create would look like it named one. +func TestCreateStillAcceptsAnUnsetCLIPath(t *testing.T) { + cmd := &cobra.Command{Use: "create"} + var cliPath string + cmd.Flags().StringVar(&cliPath, "cli-path", "/", "Path for CLI destinations") + require.NoError(t, cmd.ParseFlags([]string{})) + + flags := &destinationConfigFlags{URL: "https://api.example.com", CliPath: cliPathFromFlags(cmd, cliPath)} + config, err := buildDestinationConfigFromIndividualFlags("HTTP", flags) + require.NoError(t, err) + assert.Equal(t, "https://api.example.com", config["url"]) +} diff --git a/pkg/cmd/destination_update.go b/pkg/cmd/destination_update.go index bcb5df24..4312c54b 100644 --- a/pkg/cmd/destination_update.go +++ b/pkg/cmd/destination_update.go @@ -142,9 +142,10 @@ func (dc *destinationUpdateCmd) buildUpdateRequest(ctx context.Context, client * if dc.destType != "" { req.Type = strings.ToUpper(dc.destType) } - // The stored destination answers two questions below: what type it is, so - // the CLI delivery-policy guard can run when --type is omitted, and what - // delivery-group overrides it holds, so a bare groups object does not + // The stored destination answers two questions below: what type it is, which + // decides how every type-dependent flag is read when --type is omitted — the + // delivery-policy guard and the --url/--cli-path config fields alike — and + // what delivery-group overrides it holds, so a bare groups object does not // destroy them. Fetch it at most once, and only when it is needed. var ( existingDest *hookdeck.Destination @@ -162,9 +163,13 @@ func (dc *destinationUpdateCmd) buildUpdateRequest(ctx context.Context, client * return found, nil } - // --type is normally omitted on update, so the delivery-policy guard has to - // resolve the stored type or it never fires for the common invocation. - policyType, err := destinationTypeForPolicyCheck( + // --type is normally omitted on update, so the stored type has to be + // resolved or nothing that depends on it works for the common invocation: + // the delivery-policy guard never fires, and config building drops --url and + // --cli-path on the floor (#406). The resolved type is what config building + // gets, not just what the guard gets — anything narrower would make --url + // work only in the company of other flags. + resolvedType, err := resolveDestinationType( dc.destType, dc.config != "" || dc.configFile != "", &dc.destinationConfigFlags, @@ -174,11 +179,11 @@ func (dc *destinationUpdateCmd) buildUpdateRequest(ctx context.Context, client * return nil, err } - config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, resolvedType, &dc.destinationConfigFlags) if err != nil { return nil, err } - if err := rejectDeliveryPolicyInConfigForCLI(policyType, config, ""); err != nil { + if err := rejectDeliveryPolicyInConfigForCLI(resolvedType, config, ""); err != nil { return nil, err } // update is a PUT and the API replaces delivery_policy.groups wholesale, so diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index 35925177..e31fb2b3 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -145,8 +145,9 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * dc.destinationConfigFlags.URL = dc.url dc.destinationConfigFlags.CliPath = dc.cliPath - // The stored destination answers two separate questions below: what type it - // is, so the CLI delivery-policy guard can run when --type is omitted, and + // The stored destination answers two questions below: what type it is, which + // decides how every type-dependent flag is read when --type is omitted — the + // delivery-policy guard and the --url/--cli-path config fields alike — and // what delivery-group overrides it holds, so a bare groups object does not // destroy them. Fetch it at most once, and only when it is needed. var ( @@ -165,14 +166,11 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * return found, nil } - config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) - if err != nil { - return nil, err - } - - // --type is normally omitted on upsert, so the delivery-policy guard has to - // resolve the stored type or it never fires for the common invocation. - policyType, err := destinationTypeForPolicyCheck( + // --type is normally omitted on upsert, so the stored type has to be + // resolved before the config is built or nothing that depends on it works: + // the delivery-policy guard never fires, and --url and --cli-path are left + // out of the request body entirely (#406). + resolvedType, err := resolveDestinationType( dc.destType, dc.config != "" || dc.configFile != "", &dc.destinationConfigFlags, @@ -181,18 +179,25 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * if err != nil { return nil, err } - if err := rejectDeliveryPolicyInConfigForCLI(policyType, config, ""); err != nil { + + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, resolvedType, &dc.destinationConfigFlags) + if err != nil { + return nil, err + } + if err := rejectDeliveryPolicyInConfigForCLI(resolvedType, config, ""); err != nil { return nil, err } - t := strings.ToUpper(dc.destType) + // Overlay for the --config path, where the config JSON was returned verbatim + // and the individual flag still has to win. + rt := strings.ToUpper(resolvedType) if config == nil { config = make(map[string]interface{}) } - if t == "HTTP" && dc.url != "" { + if rt == "HTTP" && dc.url != "" { config["url"] = dc.url } - if t == "CLI" { + if rt == "CLI" { applyCLIPath(config, dc.cliPath, false) } @@ -202,8 +207,14 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * if dc.description != "" { req.Description = &dc.description } - if t != "" { - req.Type = t + // The resolved type goes on the request as well. This is a PUT against the + // collection, so a body carrying a config needs to say what kind of + // destination it is — and applyStoredDestinationConfig already adopts the + // stored type on the path where no config is sent at all. Resolving it never + // changes the type: it is either the one the user passed or the one the + // destination already has. + if rt != "" { + req.Type = rt } if len(config) > 0 { req.Config = config diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index abeb8abb..daa9ae41 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -29,6 +29,10 @@ Requires --start and --end. When querying per-issue (e.g. --dimensions issue_id), --issue-id is required. +Each query is answered by a single endpoint, so a request cannot span two of +them: queue_depth, max_depth, max_age and pending each select their own, and +none of them can be combined with per-issue (--dimensions issue_id, --issue-id). + Measures: ` + metricsEventsMeasures + `. Dimensions: ` + metricsEventsDimensions + `.`), RunE: c.runE, @@ -67,11 +71,13 @@ func hasDimension(params hookdeck.MetricsQueryParams, name string) bool { // queryEventMetricsConsolidated routes to the correct underlying API endpoint // based on the requested measures and dimensions. func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, params hookdeck.MetricsQueryParams) (hookdeck.MetricsResponse, error) { - // Only one endpoint is called, so measures belonging to different ones - // cannot all be answered: the surplus would be dropped or rewritten into a - // 422. Refuse the combination by name rather than exit 0 with less data - // than was asked for. - if err := hookdeck.RejectMixedMeasureRoutes(params.Measures, "--measures"); err != nil { + // Only one endpoint is called, so a query that names parts of two routes + // cannot be answered in full: the surplus measures would be dropped or + // rewritten into a 422, and the conditions below are ordered, so a + // queue-depth or pending measure silently shadowed the issue_id dimension + // and the --issue-id filter (#407). Refuse the combination by name rather + // than exit 0 having answered a different question. + if err := hookdeck.RejectCrossRouteEventQuery(params, "--measures", "--dimensions", hookdeck.CLIFilterNames); err != nil { return nil, err } // Route based on measures/dimensions: diff --git a/pkg/cmd/metrics_events_routing_test.go b/pkg/cmd/metrics_events_routing_test.go index a9cd8224..070e79bf 100644 --- a/pkg/cmd/metrics_events_routing_test.go +++ b/pkg/cmd/metrics_events_routing_test.go @@ -156,3 +156,102 @@ func TestSingleRouteMeasureCombinationsStillWork(t *testing.T) { assert.Contains(t, *path, "metrics/events") }) } + +// TestCrossRouteMeasureAndDimensionIsRejected pins #407. The routing conditions +// are ordered and the first match wins, so a queue-depth measure decided the +// endpoint and the issue_id dimension went to /metrics/queue-depth, which does +// not group by issue: the command exited 0 having answered a different +// question. "pending" shadowed it in exactly the same way. +func TestCrossRouteMeasureAndDimensionIsRejected(t *testing.T) { + tests := []struct { + name string + params hookdeck.MetricsQueryParams + contains []string + }{ + { + name: "queue depth measure with the issue_id dimension", + params: hookdeck.MetricsQueryParams{ + Measures: []string{"queue_depth"}, + Dimensions: []string{"issue_id"}, + IssueID: "iss_1", + }, + contains: []string{"--measures", `"queue_depth"`, "queue depth metrics", "--dimensions", `"issue_id"`, "per-issue event metrics"}, + }, + { + name: "the dimension conflicts even without the filter", + params: hookdeck.MetricsQueryParams{ + Measures: []string{"max_age"}, + Dimensions: []string{"issue_id"}, + }, + contains: []string{`"max_age"`, "queue depth metrics", "per-issue event metrics"}, + }, + { + name: "the --issue-id filter selects the route on its own", + params: hookdeck.MetricsQueryParams{ + Measures: []string{"queue_depth"}, + IssueID: "iss_1", + }, + contains: []string{`"queue_depth"`, "queue depth metrics", "--issue-id", "per-issue event metrics"}, + }, + { + name: "pending shadows the issue route the same way", + params: hookdeck.MetricsQueryParams{ + Measures: []string{"pending"}, + Dimensions: []string{"issue_id"}, + IssueID: "iss_1", + }, + contains: []string{`"pending"`, "pending event metrics", "per-issue event metrics"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, path, _ := routeCapture(t) + + _, err := queryEventMetricsConsolidated(context.Background(), client, tt.params) + require.Error(t, err, "one route's numbers must not be returned under another route's question") + for _, want := range tt.contains { + assert.Contains(t, err.Error(), want) + } + assert.Empty(t, *path, "the API must not be called at all") + }) + } +} + +// TestCompatibleMeasureAndDimensionStillRoute is the other half: the default +// route is what the by-issue endpoint refines, not what it contradicts, and a +// dimension that selects no route of its own must not trip the guard. +func TestCompatibleMeasureAndDimensionStillRoute(t *testing.T) { + t.Run("a default-route measure grouped by issue goes to the issue endpoint", func(t *testing.T) { + client, path, query := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, hookdeck.MetricsQueryParams{ + Measures: []string{"count"}, + Dimensions: []string{"issue_id"}, + IssueID: "iss_1", + }) + require.NoError(t, err) + assert.Contains(t, *path, "events-by-issue") + assert.Equal(t, []string{"count"}, (*query)["measures[]"]) + }) + + t.Run("queue depth grouped by a dimension that selects no route still routes", func(t *testing.T) { + client, path, _ := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, hookdeck.MetricsQueryParams{ + Measures: []string{"queue_depth"}, + Dimensions: []string{"destination_id"}, + }) + require.NoError(t, err) + assert.Contains(t, *path, "queue-depth") + }) + + t.Run("an unknown measure leaves the issue route to the dimension", func(t *testing.T) { + client, path, _ := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, hookdeck.MetricsQueryParams{ + Measures: []string{"not_a_measure"}, + Dimensions: []string{"issue_id"}, + IssueID: "iss_1", + }) + require.NoError(t, err, "a measure this package does not know must not decide the route") + assert.Contains(t, *path, "events-by-issue") + }) +} diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index 759dd13c..ac3255f5 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -128,6 +128,7 @@ const ( EventRouteDefault = "event metrics" EventRouteQueueDepth = "queue depth metrics" EventRoutePending = "pending event metrics" + EventRouteByIssue = "per-issue event metrics" ) // eventMeasureRoutes maps every measure `metrics events` advertises onto the API @@ -154,6 +155,25 @@ var eventMeasureRoutes = map[string]string{ "pending": EventRoutePending, } +// eventDimensionRoutes maps a dimension onto the endpoint it selects. Only +// issue_id selects a route of its own; every other dimension is grouped by +// whichever endpoint the measures choose. +var eventDimensionRoutes = map[string]string{ + "issue_id": EventRouteByIssue, +} + +// firstRoutedMeasure returns the first measure that selects an endpoint, and the +// endpoint it selects. ("", "") means the measures do not decide the route — the +// request falls through to whatever the dimensions select, or to the default. +func firstRoutedMeasure(measures []string) (string, string) { + for _, m := range measures { + if route, ok := eventMeasureRoutes[m]; ok { + return m, route + } + } + return "", "" +} + // RejectMixedMeasureRoutes refuses a measure list that spans more than one // events-metrics endpoint. // @@ -182,3 +202,53 @@ func RejectMixedMeasureRoutes(measures []string, measuresName string) error { } return nil } + +// RejectCrossRouteEventQuery refuses an events query whose parts select more +// than one API endpoint. +// +// `metrics events` fans out over four endpoints and calls exactly one of them, +// choosing it from the measures, then the issue_id dimension, then the issue +// filter. First match wins, so a request naming parts of two routes is answered +// from one of them and the rest of the question is dropped without a word: a +// queue-depth measure shadowed the issue_id dimension entirely (#407), and +// "pending" shadows it the same way. One route's numbers returned under another +// route's question are worse than no answer, so name both routes and refuse. +// +// It subsumes RejectMixedMeasureRoutes, which is the same rule applied within +// the measure list. Callers should use this and not both. +// +// measuresName and dimensionsName are the caller's own spellings of the +// arguments, and names supplies the same for the filters, so a CLI user reads +// "--measures" and an MCP client reads "measures". +func RejectCrossRouteEventQuery(params MetricsQueryParams, measuresName, dimensionsName string, names MetricsFilterNames) error { + if err := RejectMixedMeasureRoutes(params.Measures, measuresName); err != nil { + return err + } + + measure, measureRoute := firstRoutedMeasure(params.Measures) + // The default route is the one every dimension refines rather than + // contradicts: `--measures count --dimensions issue_id` is a per-issue count, + // which is exactly what the by-issue endpoint answers. + if measureRoute == "" || measureRoute == EventRouteDefault { + return nil + } + + conflict := func(selector, route string) error { + return fmt.Errorf("%s %q (%s) cannot be combined with %s (%s): these are separate API endpoints, so ask for one route at a time", + measuresName, measure, measureRoute, selector, route) + } + + for _, d := range params.Dimensions { + route, selects := eventDimensionRoutes[d] + if selects && route != measureRoute { + return conflict(fmt.Sprintf("%s %q", dimensionsName, d), route) + } + } + // The filter selects the by-issue route on its own, so it conflicts on its + // own too — and saying which two routes were asked for is more use than + // reporting it as a filter the endpoint happens to ignore. + if params.IssueID != "" && measureRoute != EventRouteByIssue { + return conflict(names.IssueID, EventRouteByIssue) + } + return nil +} From 315f46dcf93f251f14d3b6b1fa2fa4877ec6998c Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 21:12:14 +0100 Subject: [PATCH 10/16] fix: make listen's connection state affirmative in every output mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four output bugs found in v2.6.0 RC testing. #376 fixed readiness never being announced without a TTY; #399 is the same bug inverted, and is the one that matters most here. #399 — interactive mode looked connected before it was. The TUI drew its complete layout immediately — brand header, "Listening on …", "Requests to →", "Forwards to →" — and drew the status bar only once the websocket was up. A session that never connected was therefore identical to a working one apart from a line that was absent, for the whole 40-second attempt budget, before the alt-screen was torn down and an error printed. Absence is not a signal a user reads. The model now carries an explicit connection state whose zero value is "connecting", the status bar is drawn on every frame, and it leads with that state: "● Connecting…", "● Connecting… (attempt N)" while retries are in flight, "● Connected." on success, "● Reconnecting…" after a drop, and "● Connection failed: " when the CLI gives up — held briefly so it is visible inside the alt-screen rather than only after it. A failed attempt before the first connect is counted rather than reported as reconnecting, because the CLI cannot claim a connection it never had. #402 — compact output printed the bare preposition "Listening on". The counts the interactive header shows now live in pkg/listen/summary, a leaf shared by both renderers (as pkg/listen/links already is), so the two modes cannot drift apart again. Compact is the automatic no-TTY fallback, so this is the line most CI logs keep. #403 — OSC 8 hyperlinks were hand-rolled and emitted unconditionally, so redirected output carried the escape bytes and a real terminal — the only thing that can render them — carried a plain URL. They are now gated on the same check colour uses, and the plain-text fallback prints the full URL including team_id, which the hyperlink label deliberately omits. The CLI also now honours NO_COLOR, which it never had. #404 — --color off reached only pkg/ansi, and the TUI draws with lipgloss, so a controlling-pty run with the flag set still emitted 48 SGR sequences. The interactive renderer now threads the same answer into the TUI styles, which renders every frame with no SGR bytes at all. Verified across five output environments with a real openpty + TIOCSCTTY harness: the non-TTY readiness line of #376 is unchanged in compact and quiet, --color off on a TTY drops from 48 SGR sequences to 0, and compact on a TTY now emits the hyperlink while a pipe does not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- go.mod | 3 +- pkg/ansi/ansi.go | 31 +++- pkg/ansi/ansi_test.go | 136 +++++++++++++++ pkg/listen/printer.go | 27 ++- pkg/listen/printer_test.go | 186 +++++++++++++++++++++ pkg/listen/proxy/proxy.go | 13 +- pkg/listen/proxy/renderer.go | 5 + pkg/listen/proxy/renderer_interactive.go | 23 ++- pkg/listen/proxy/renderer_simple.go | 7 + pkg/listen/proxy/renderer_simple_test.go | 23 +++ pkg/listen/summary/summary.go | 29 ++++ pkg/listen/summary/summary_test.go | 31 ++++ pkg/listen/tui/model.go | 39 ++++- pkg/listen/tui/styles.go | 118 ++++++++----- pkg/listen/tui/styles_test.go | 111 ++++++++++++ pkg/listen/tui/update.go | 19 +++ pkg/listen/tui/view.go | 161 ++++++++++++------ pkg/listen/tui/view_test.go | 197 ++++++++++++++++++++++ test/acceptance/listen_tty_test.go | 204 +++++++++++++++++++++++ 19 files changed, 1251 insertions(+), 112 deletions(-) create mode 100644 pkg/ansi/ansi_test.go create mode 100644 pkg/listen/printer_test.go create mode 100644 pkg/listen/summary/summary.go create mode 100644 pkg/listen/summary/summary_test.go create mode 100644 pkg/listen/tui/styles_test.go create mode 100644 pkg/listen/tui/view_test.go create mode 100644 test/acceptance/listen_tty_test.go diff --git a/go.mod b/go.mod index 6a9f1e08..c03be2d7 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,13 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.17 github.com/gorilla/websocket v1.5.3 github.com/gosimple/slug v1.15.0 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mitchellh/go-homedir v1.1.0 github.com/modelcontextprotocol/go-sdk v1.7.0 + github.com/muesli/termenv v0.16.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -52,7 +54,6 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/onsi/ginkgo v1.14.1 // indirect github.com/onsi/gomega v1.10.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/pkg/ansi/ansi.go b/pkg/ansi/ansi.go index d5116d23..6973d786 100644 --- a/pkg/ansi/ansi.go +++ b/pkg/ansi/ansi.go @@ -101,10 +101,30 @@ func Italic(text string) string { return color.Sprintf(color.Italic(text)) } +// ShouldUseColors reports whether ANSI decoration may be written to w, taking +// --color, CLICOLOR/CLICOLOR_FORCE, NO_COLOR and whether w is a terminal into +// account. Renderers that draw with something other than this package (the +// interactive TUI draws with lipgloss) need the same answer, or --color off +// silently applies to one output mode and not the other (#404). +func ShouldUseColors(w io.Writer) bool { + return shouldUseColors(w) +} + +// CanHyperlink reports whether OSC 8 hyperlinks can be written to w. It is the +// same test colour uses, deliberately: a hyperlink is terminal decoration, so it +// belongs wherever colour belongs and nowhere else. +// +// Before #403 the listen printer emitted OSC 8 unconditionally, which inverted +// the rule — a piped run (a log file, a CI job) got the escape bytes while a +// real terminal, the only thing that can render them, got a plain URL. +func CanHyperlink(w io.Writer) bool { + return ShouldUseColors(w) +} + // Linkify returns an ANSI escape sequence with an hyperlink, if the writer // supports colors. func Linkify(text, url string, w io.Writer) string { - if !shouldUseColors(w) { + if !CanHyperlink(w) { return text } @@ -220,5 +240,14 @@ func shouldUseColors(w io.Writer) bool { } } + // https://no-color.org: any non-empty NO_COLOR turns decoration off. It is + // checked after CLICOLOR_FORCE and before DisableColors so an explicit + // --color on still wins, matching how the other overrides are layered. + if !ForceColors { + if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" { + useColors = false + } + } + return useColors && !DisableColors } diff --git a/pkg/ansi/ansi_test.go b/pkg/ansi/ansi_test.go new file mode 100644 index 00000000..18b52310 --- /dev/null +++ b/pkg/ansi/ansi_test.go @@ -0,0 +1,136 @@ +package ansi + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetColorState restores the package globals a test flipped, so the next test +// sees the default configuration. +func resetColorState(t *testing.T) { + t.Helper() + force, disable := ForceColors, DisableColors + t.Cleanup(func() { + ForceColors, DisableColors = force, disable + }) +} + +// TestCanHyperlinkFollowsColor pins #403. OSC 8 hyperlinks were written +// unconditionally by the listen printer, which meant a redirected run (a log +// file, a CI job) received the escape bytes while a real terminal — the only +// thing that can render them — received a plain URL. A hyperlink is terminal +// decoration and must be gated exactly like colour. +func TestCanHyperlinkFollowsColor(t *testing.T) { + t.Run("a plain writer is not a terminal, so no hyperlink", func(t *testing.T) { + resetColorState(t) + + assert.False(t, CanHyperlink(&bytes.Buffer{})) + }) + + t.Run("a colour-capable writer can be hyperlinked", func(t *testing.T) { + resetColorState(t) + ForceColors = true + + assert.True(t, CanHyperlink(&bytes.Buffer{})) + }) + + t.Run("--color off suppresses hyperlinks too", func(t *testing.T) { + resetColorState(t) + ForceColors = true + DisableColors = true + + assert.False(t, CanHyperlink(&bytes.Buffer{}), + "--color off must strip the OSC 8 bytes, not just the SGR ones") + }) + + t.Run("CanHyperlink and ShouldUseColors agree", func(t *testing.T) { + resetColorState(t) + w := &bytes.Buffer{} + + for _, force := range []bool{false, true} { + for _, disable := range []bool{false, true} { + ForceColors, DisableColors = force, disable + assert.Equal(t, ShouldUseColors(w), CanHyperlink(w), + "hyperlinks and colour must be gated by one decision") + } + } + }) +} + +// TestLinkifyEmitsOSC8OnlyWhenItCanBeRendered checks the bytes themselves. +func TestLinkifyEmitsOSC8OnlyWhenItCanBeRendered(t *testing.T) { + const url = "https://dashboard.hookdeck.com/events/cli?team_id=tm_1" + + t.Run("no terminal, no escape", func(t *testing.T) { + resetColorState(t) + + out := Linkify("label", url, &bytes.Buffer{}) + + assert.Equal(t, "label", out) + assert.NotContains(t, out, "\x1b]8;;") + }) + + t.Run("terminal gets the escape", func(t *testing.T) { + resetColorState(t) + ForceColors = true + + out := Linkify("label", url, &bytes.Buffer{}) + + assert.Equal(t, 2, strings.Count(out, "\x1b]8;;"), + "an OSC 8 hyperlink opens and closes") + assert.Contains(t, out, url) + }) +} + +// TestNoColorDisablesDecoration covers the NO_COLOR half of #403. The CLI +// honoured --color off and CLICOLOR but had never implemented https://no-color.org, +// so NO_COLOR only appeared to work in the places where output was not a +// terminal anyway. +func TestNoColorDisablesDecoration(t *testing.T) { + t.Run("NO_COLOR turns decoration off", func(t *testing.T) { + resetColorState(t) + t.Setenv("NO_COLOR", "1") + t.Setenv("CLICOLOR_FORCE", "1") // would otherwise force colour on + + assert.False(t, ShouldUseColors(&bytes.Buffer{})) + assert.False(t, CanHyperlink(&bytes.Buffer{})) + }) + + t.Run("an empty NO_COLOR is not set", func(t *testing.T) { + resetColorState(t) + t.Setenv("NO_COLOR", "") + ForceColors = true + + assert.True(t, ShouldUseColors(&bytes.Buffer{}), + "the spec treats only a non-empty value as set") + }) + + t.Run("--color on still wins", func(t *testing.T) { + resetColorState(t) + t.Setenv("NO_COLOR", "1") + ForceColors = true + + assert.True(t, ShouldUseColors(&bytes.Buffer{}), + "an explicit flag beats an environment default") + }) +} + +// TestCanSpinStillRequiresATerminal guards the #376 fix: readiness reporting +// branches on CanSpin, and it must stay false for a non-terminal so the plain +// status line is printed instead of a spinner nobody can see. +func TestCanSpinStillRequiresATerminal(t *testing.T) { + resetColorState(t) + ForceColors = true + + assert.False(t, CanSpin(&bytes.Buffer{}), "a buffer is not a terminal") + + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + t.Cleanup(func() { _ = devNull.Close() }) + assert.False(t, CanSpin(devNull), "/dev/null is a file, not a terminal") +} diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 2bb43409..dbf16f74 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -2,15 +2,32 @@ package listen import ( "fmt" + "io" "net/url" + "os" "strings" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/listen/links" + "github.com/hookdeck/hookdeck-cli/pkg/listen/summary" ) +// hyperlink renders url as an OSC 8 hyperlink labelled display when w can show +// one, and as the full url — query parameters and all — when it cannot. +// +// Both halves matter for #403. Emitting the escape to a pipe wrote bytes nothing +// downstream can render; and because the label deliberately omits team_id, the +// plain-text fallback has to be the real url or the redirected output ends up +// carrying *less* information than the terminal output it replaced. +func hyperlink(url, display string, w io.Writer) string { + if !ansi.CanHyperlink(w) { + return url + } + return ansi.Linkify(display, url, w) +} + func printSourcesWithConnections(config *config.Config, projectID string, sources []*hookdeck.Source, connections []*hookdeck.Connection, targetURL *url.URL, guestURL string) { // Group connections by source ID sourceConnections := make(map[string][]*hookdeck.Connection) @@ -19,8 +36,10 @@ func printSourcesWithConnections(config *config.Config, projectID string, source sourceConnections[sourceID] = append(sourceConnections[sourceID], connection) } - // Print the Sources title line - fmt.Printf("%s\n", ansi.Faint("Listening on")) + // Print the Sources title line. It carries the same counts as the + // interactive header: compact is the automatic no-TTY fallback, so this is + // the line most CI logs keep, and a bare "Listening on" told them nothing. + fmt.Printf("%s\n", ansi.Faint(summary.Listening(len(sources), len(connections)))) fmt.Println() // Print each source with its connections @@ -83,8 +102,6 @@ func printSourcesWithConnections(config *config.Config, projectID string, source } else { url := links.DashboardHome(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectType, projectID) displayURL := links.DashboardHomeDisplay(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectType) - // Create clickable link with OSC 8 hyperlink sequence - // Format: \033]8;;URL\033\\DISPLAY_TEXT\033]8;;\033\\ - fmt.Printf("💡 Open dashboard to inspect, retry & bookmark events: \033]8;;%s\033\\%s\033]8;;\033\\\n", url, displayURL) + fmt.Printf("💡 Open dashboard to inspect, retry & bookmark events: %s\n", hyperlink(url, displayURL, os.Stdout)) } } diff --git a/pkg/listen/printer_test.go b/pkg/listen/printer_test.go new file mode 100644 index 00000000..672f384c --- /dev/null +++ b/pkg/listen/printer_test.go @@ -0,0 +1,186 @@ +package listen + +import ( + "io" + "net/url" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// capturePrinterOutput runs fn with os.Stdout redirected and returns what it +// wrote. printSourcesWithConnections prints with fmt.Printf and decides on +// hyperlinks from os.Stdout, so the pipe here is also the "not a terminal" case. +func capturePrinterOutput(t *testing.T, fn func()) string { + t.Helper() + + original := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + done := make(chan string, 1) + go func() { + out, _ := io.ReadAll(r) + done <- string(out) + }() + + fn() + + require.NoError(t, w.Close()) + os.Stdout = original + + return <-done +} + +func printerFixture(t *testing.T, numConnections int) (*config.Config, []*hookdeck.Source, []*hookdeck.Connection, *url.URL) { + t.Helper() + + targetURL, err := url.Parse("http://localhost:3030") + require.NoError(t, err) + + source := &hookdeck.Source{ID: "src_1", Name: "my-source", URL: "https://hkdk.events/src_1"} + + connections := make([]*hookdeck.Connection, 0, numConnections) + for i := 0; i < numConnections; i++ { + fullName := "my-source -> dest" + connections = append(connections, &hookdeck.Connection{ + ID: "web_1", + FullName: &fullName, + Source: source, + Destination: &hookdeck.Destination{ID: "des_1", Type: "CLI", Config: map[string]interface{}{"path": "/"}}, + }) + } + + cfg := &config.Config{ + DashboardBaseURL: "https://dashboard.hookdeck.com", + ConsoleBaseURL: "https://console.hookdeck.com", + } + + return cfg, []*hookdeck.Source{source}, connections, targetURL +} + +// resetColorState restores the ansi package globals a subtest flipped. +func resetColorState(t *testing.T) { + t.Helper() + force, disable := ansi.ForceColors, ansi.DisableColors + t.Cleanup(func() { + ansi.ForceColors, ansi.DisableColors = force, disable + }) +} + +// TestCompactBannerCarriesTheSummary is the regression test for #402. Compact +// output line 2 was the bare preposition "Listening on" followed by a blank +// line: the renderer had the sources and connections but printed neither. +// Compact is the automatic no-TTY fallback, so this is the line CI logs keep. +func TestCompactBannerCarriesTheSummary(t *testing.T) { + t.Run("one source, one connection", func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, targetURL := printerFixture(t, 1) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Contains(t, out, "Listening on 1 source • 1 connection") + assert.NotRegexp(t, `(?m)^Listening on\s*$`, out, + "a dangling preposition is worse than nothing (#402)") + }) + + t.Run("counts are pluralised", func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, targetURL := printerFixture(t, 2) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Contains(t, out, "Listening on 1 source • 2 connections") + }) +} + +// TestDashboardLinkHyperlinksOnlyOnATerminal is the regression test for #403. +// The printer hand-rolled the OSC 8 escape unconditionally, so the measurement +// came out exactly backwards: redirected output carried two escape sequences and +// a real terminal carried none. Worse, the link label omits team_id, so the +// redirected output was strictly *less* informative than the terminal output. +func TestDashboardLinkHyperlinksOnlyOnATerminal(t *testing.T) { + const fullURL = "https://dashboard.hookdeck.com/events/cli?team_id=tm_1" + + t.Run("not a terminal: no escape, and the full URL including team_id", func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, targetURL := printerFixture(t, 1) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Equal(t, 0, strings.Count(out, "\x1b]8;;"), + "a log file cannot render OSC 8") + assert.Contains(t, out, fullURL, + "without the hyperlink the query parameters must be visible, not hidden in an escape") + }) + + t.Run("a terminal gets the hyperlink", func(t *testing.T) { + resetColorState(t) + ansi.ForceColors = true + cfg, sources, connections, targetURL := printerFixture(t, 1) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Equal(t, 2, strings.Count(out, "\x1b]8;;"), + "a terminal is the one thing that can render OSC 8") + assert.Contains(t, out, fullURL, "the escape still targets the full URL") + }) + + t.Run("--color off strips the hyperlink and keeps the full URL", func(t *testing.T) { + resetColorState(t) + ansi.ForceColors = true + ansi.DisableColors = true + cfg, sources, connections, targetURL := printerFixture(t, 1) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Equal(t, 0, strings.Count(out, "\x1b]8;;"), + "--color off left the OSC 8 bytes behind (#403)") + assert.Contains(t, out, fullURL) + }) + + t.Run("NO_COLOR strips the hyperlink too", func(t *testing.T) { + resetColorState(t) + ansi.ForceColors = false + t.Setenv("NO_COLOR", "1") + t.Setenv("CLICOLOR_FORCE", "1") + cfg, sources, connections, targetURL := printerFixture(t, 1) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Equal(t, 0, strings.Count(out, "\x1b]8;;")) + assert.Contains(t, out, fullURL) + }) + + t.Run("a guest session prints its sign-up link plainly", func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, targetURL := printerFixture(t, 1) + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "https://hookdeck.com/signup?x=1") + }) + + assert.Contains(t, out, "https://hookdeck.com/signup?x=1") + assert.Equal(t, 0, strings.Count(out, "\x1b]8;;")) + }) +} diff --git a/pkg/listen/proxy/proxy.go b/pkg/listen/proxy/proxy.go index e5513a24..77ca3b2a 100644 --- a/pkg/listen/proxy/proxy.go +++ b/pkg/listen/proxy/proxy.go @@ -274,15 +274,22 @@ func (p *Proxy) Run(parentCtx context.Context) error { nAttempts = 0 } if !canConnect() { - p.renderer.Cleanup() // Report the reason, not just the count. Without this the user is // told the CLI gave up but not whether it was DNS, a refused // connection, a proxy, or a rejected session — and the reason is // only logged at debug level. + var giveUpErr error if connectErr := wsClient.LastConnectErr(); connectErr != nil { - return fmt.Errorf("Could not connect. Terminating after %d failed attempts to establish a connection. Last error: %v", nAttempts, connectErr) + giveUpErr = fmt.Errorf("Could not connect. Terminating after %d failed attempts to establish a connection. Last error: %v", nAttempts, connectErr) + } else { + giveUpErr = fmt.Errorf("Could not connect. Terminating after %d failed attempts to establish a connection.", nAttempts) } - return fmt.Errorf("Could not connect. Terminating after %d failed attempts to establish a connection.", nAttempts) + // Say so in the renderer before tearing it down. The interactive + // renderer otherwise spends the whole attempt budget looking live + // and then vanishes (#399). + p.renderer.OnConnectionFailed(giveUpErr) + p.renderer.Cleanup() + return giveUpErr } } diff --git a/pkg/listen/proxy/renderer.go b/pkg/listen/proxy/renderer.go index bb43b3f3..ca58e9ff 100644 --- a/pkg/listen/proxy/renderer.go +++ b/pkg/listen/proxy/renderer.go @@ -17,6 +17,11 @@ type Renderer interface { OnConnected() OnDisconnected() OnError(err error) + // OnConnectionFailed reports that the CLI has given up connecting. It exists + // so the interactive renderer can show an affirmative failure state before it + // tears the alt-screen down (#399); the non-interactive renderers leave it to + // the error the command already prints, so their output is unchanged. + OnConnectionFailed(err error) // Event handling OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) // For interactive mode (100ms delay) diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index 5c0c05bc..901273df 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -50,6 +50,11 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { AppConfig: cfg.AppConfig, } + // --color off (and NO_COLOR) has to reach the TUI too. It draws with lipgloss, + // which never consulted pkg/ansi, so the flag only ever applied to the compact + // renderer and a --color off TUI run still emitted SGR sequences (#404). + tui.SetColorEnabled(ansi.ShouldUseColors(os.Stdout)) + model := tui.NewModel(tuiCfg) program := tea.NewProgram(&model, tea.WithAltScreen()) @@ -102,7 +107,23 @@ func (r *InteractiveRenderer) OnDisconnected() { // OnError is called when an error occurs func (r *InteractiveRenderer) OnError(err error) { - // Errors are handled through OnEventError + // Per-event errors are handled through OnEventError. A session-level error + // means there is no connection, which the status bar has to say out loud. + r.OnConnectionFailed(err) +} + +// failedStateLinger is how long the failure frame is held before the TUI is torn +// down, so the user sees why the CLI stopped inside the alt-screen rather than +// only in the error printed after it. +const failedStateLinger = 500 * time.Millisecond + +// OnConnectionFailed shows the failure state in the status bar. +func (r *InteractiveRenderer) OnConnectionFailed(err error) { + if r.teaProgram == nil { + return + } + r.teaProgram.Send(tui.ConnectionFailedMsg{Err: err}) + time.Sleep(failedStateLinger) } // OnEventPending is called when an event starts (after 100ms delay) diff --git a/pkg/listen/proxy/renderer_simple.go b/pkg/listen/proxy/renderer_simple.go index afdd6805..ec022ede 100644 --- a/pkg/listen/proxy/renderer_simple.go +++ b/pkg/listen/proxy/renderer_simple.go @@ -125,6 +125,13 @@ func (r *SimpleRenderer) OnError(err error) { fmt.Printf("%s %v\n", color.Red("ERROR:"), err) } +// OnConnectionFailed is a no-op for the simple renderer. Giving up connecting +// already surfaces as the error `listen` prints on exit, and printing it twice +// would change output that callers and CI now parse. +func (r *SimpleRenderer) OnConnectionFailed(err error) { + r.stopStatus() +} + // OnEventPending is called when an event starts (not used in simple renderer) func (r *SimpleRenderer) OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) { // Simple renderer doesn't show pending events diff --git a/pkg/listen/proxy/renderer_simple_test.go b/pkg/listen/proxy/renderer_simple_test.go index 4e6368d3..ab9ded9b 100644 --- a/pkg/listen/proxy/renderer_simple_test.go +++ b/pkg/listen/proxy/renderer_simple_test.go @@ -1,6 +1,7 @@ package proxy import ( + "errors" "io" "net/url" "os" @@ -130,3 +131,25 @@ func TestSimpleRendererAnnouncesReadinessWithoutASpinner(t *testing.T) { "a recovered connection is a state change worth reporting") }) } + +// TestSimpleRendererStaysSilentOnConnectionFailure guards the #376 fix against +// the #399 change. OnConnectionFailed was added so the interactive renderer can +// show a failure state before tearing the alt-screen down; the simple renderer +// must not use it to print anything, because `listen` already prints the same +// error on exit and non-interactive output is now parsed by callers and CI. +func TestSimpleRendererStaysSilentOnConnectionFailure(t *testing.T) { + target, err := url.Parse("http://localhost:3000") + require.NoError(t, err) + + r := NewSimpleRenderer(&RendererConfig{TargetURL: target}, false) + + out := captureStdout(t, func() { + r.OnConnecting() + r.OnConnectionFailed(errors.New("Could not connect. Terminating after 10 failed attempts")) + }) + + assert.NotContains(t, out, "Could not connect", + "the command prints this error itself; the renderer must not duplicate it") + assert.Contains(t, out, "Getting ready...", + "the pending line is still the state machine the caller reads") +} diff --git a/pkg/listen/summary/summary.go b/pkg/listen/summary/summary.go new file mode 100644 index 00000000..22a0f90e --- /dev/null +++ b/pkg/listen/summary/summary.go @@ -0,0 +1,29 @@ +// Package summary builds the one-line "Listening on …" banner for listen +// output. Like pkg/listen/links it is a leaf shared by the interactive TUI and +// the compact/quiet printer, so the two output modes cannot drift apart. +package summary + +import "fmt" + +// Listening returns the session summary line, e.g. +// "Listening on 1 source • 2 connections". +// +// The compact printer used to print the bare preposition "Listening on" and +// nothing else, because it never had the counts the TUI header carried (#402). +// A dangling preposition is the line most CI logs carry, so the counts live +// here where both renderers reach them. +func Listening(numSources, numConnections int) string { + return fmt.Sprintf("Listening on %s • %s", + pluralize(numSources, "source"), + pluralize(numConnections, "connection"), + ) +} + +// pluralize renders a count with its noun, adding a plural "s" for anything +// other than exactly one. +func pluralize(count int, noun string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, noun) + } + return fmt.Sprintf("%d %ss", count, noun) +} diff --git a/pkg/listen/summary/summary_test.go b/pkg/listen/summary/summary_test.go new file mode 100644 index 00000000..cc89c393 --- /dev/null +++ b/pkg/listen/summary/summary_test.go @@ -0,0 +1,31 @@ +package summary + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestListeningAlwaysCarriesCounts pins #402: the summary must never degrade to +// a bare preposition. Both renderers build their banner from this string, so an +// empty or count-less result here is the compact-mode bug. +func TestListeningAlwaysCarriesCounts(t *testing.T) { + tests := []struct { + name string + sources int + connections int + want string + }{ + {"singular", 1, 1, "Listening on 1 source • 1 connection"}, + {"plural", 2, 3, "Listening on 2 sources • 3 connections"}, + {"zero is still plural", 0, 0, "Listening on 0 sources • 0 connections"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Listening(tt.sources, tt.connections) + assert.Equal(t, tt.want, got) + assert.NotEqual(t, "Listening on", got, "a bare preposition is worse than nothing (#402)") + }) + } +} diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index 7e11de85..8f9ebc52 100644 --- a/pkg/listen/tui/model.go +++ b/pkg/listen/tui/model.go @@ -54,9 +54,21 @@ type Model struct { userNavigated bool // Track if user has manually navigated away from latest // UI state - ready bool - hasReceivedEvent bool - isConnected bool + ready bool + hasReceivedEvent bool + isConnected bool + // connState is the affirmative connection state drawn in the status bar on + // every frame. #399: the TUI used to render the full layout with an empty + // status bar while nothing was connected, so "connected" and "failing to + // connect for 40 seconds" differed only by the presence of one line — and an + // absent line is not a signal anyone reads. + connState connectionState + // connAttempts counts connection attempts that have failed before the first + // successful connect, so the pending state can say it is making progress + // rather than looking stuck. + connAttempts int + // connErr is the reason the connection failed, shown in the failure state. + connErr error waitingFrameToggle bool width int height int @@ -84,6 +96,20 @@ type Model struct { serverHealthChecked bool } +// connectionState is what the status bar reports about the websocket. +type connectionState uint8 + +const ( + // connConnecting is the state every session starts in, before the websocket + // is up. It is deliberately the zero value: a Model that has been told + // nothing yet must render "Connecting…", never a blank or connected-looking + // status bar. + connConnecting connectionState = iota + connConnected + connReconnecting + connFailed +) + // Config holds configuration for the TUI type Config struct { DeviceName string @@ -111,6 +137,7 @@ func NewModel(cfg *Config) Model { selectedIndex: -1, ready: false, isConnected: false, + connState: connConnecting, clipboardWrite: clipboard.WriteAll, } } @@ -543,3 +570,9 @@ type ServerHealthMsg struct { Healthy bool Error error } + +// ConnectionFailedMsg is sent when the CLI gives up connecting, so the failure +// is visible in the TUI rather than only after the alt-screen is torn down. +type ConnectionFailedMsg struct { + Err error +} diff --git a/pkg/listen/tui/styles.go b/pkg/listen/tui/styles.go index e5a6bca9..ea145398 100644 --- a/pkg/listen/tui/styles.go +++ b/pkg/listen/tui/styles.go @@ -14,61 +14,93 @@ var ( colorFaint = lipgloss.Color("240") // Faint gray colorPurple = lipgloss.Color("5") // Purple for brand accent colorCyan = lipgloss.Color("6") // Cyan for brand accent + colorBlue = lipgloss.Color("4") // Blue for the brand header + colorWhite = lipgloss.Color("7") // White/default for selection and status bar +) - // Base styles - faintStyle = lipgloss.NewStyle(). - Foreground(colorFaint) - - boldStyle = lipgloss.NewStyle(). - Bold(true) - - greenStyle = lipgloss.NewStyle(). - Foreground(colorGreen) - - redStyle = lipgloss.NewStyle(). - Foreground(colorRed). - Bold(true) - - yellowStyle = lipgloss.NewStyle(). - Foreground(colorYellow) +// Base styles +var ( + faintStyle lipgloss.Style + boldStyle lipgloss.Style + greenStyle lipgloss.Style + redStyle lipgloss.Style - cyanStyle = lipgloss.NewStyle(). - Foreground(colorCyan) + yellowStyle lipgloss.Style + cyanStyle lipgloss.Style // Brand styles - brandStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("4")). // Blue - Bold(true) - - brandAccentStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("4")) // Blue + brandStyle lipgloss.Style + brandAccentStyle lipgloss.Style // Component styles - selectionIndicatorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("7")) // White/default - - sectionTitleStyle = faintStyle.Copy() - - statusBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("7")) + selectionIndicatorStyle lipgloss.Style + sectionTitleStyle lipgloss.Style + statusBarStyle lipgloss.Style + waitingDotStyle lipgloss.Style + connectingDotStyle lipgloss.Style + dividerStyle lipgloss.Style - waitingDotStyle = greenStyle.Copy() + // Status code color styles + successStatusStyle lipgloss.Style + errorStatusStyle lipgloss.Style + warningStatusStyle lipgloss.Style +) - connectingDotStyle = yellowStyle.Copy() +// colorEnabled records whether the TUI may emit ANSI decoration. The interactive +// renderer sets it from the same answer ansi.ShouldUseColors gives the compact +// renderer; see SetColorEnabled. +var colorEnabled = true - dividerStyle = lipgloss.NewStyle(). - Foreground(colorFaint) +func init() { + buildStyles() +} - // Status code color styles - successStatusStyle = lipgloss.NewStyle(). - Foreground(colorGreen) +// SetColorEnabled turns TUI decoration on or off, and must be called before the +// Bubble Tea program starts. +// +// #404: --color off reached only the compact renderer, because the TUI draws +// with lipgloss rather than pkg/ansi. A controlling-pty run with --color off +// still emitted 48 SGR sequences. With colour disabled every style below becomes +// a bare lipgloss.Style, which renders its input unchanged, so the frames carry +// no SGR bytes at all — bold and faint included, matching what --color off means +// everywhere else in the CLI. +func SetColorEnabled(enabled bool) { + colorEnabled = enabled + buildStyles() +} - errorStatusStyle = lipgloss.NewStyle(). - Foreground(colorRed) +// decorated returns the styled variant when colour is on and a plain style when +// it is off. Every style in this file is built through it so no decoration can +// be added that --color off fails to suppress. +func decorated(style lipgloss.Style) lipgloss.Style { + if !colorEnabled { + return lipgloss.NewStyle() + } + return style +} - warningStatusStyle = lipgloss.NewStyle(). - Foreground(colorYellow) -) +func buildStyles() { + faintStyle = decorated(lipgloss.NewStyle().Foreground(colorFaint)) + boldStyle = decorated(lipgloss.NewStyle().Bold(true)) + greenStyle = decorated(lipgloss.NewStyle().Foreground(colorGreen)) + redStyle = decorated(lipgloss.NewStyle().Foreground(colorRed).Bold(true)) + yellowStyle = decorated(lipgloss.NewStyle().Foreground(colorYellow)) + cyanStyle = decorated(lipgloss.NewStyle().Foreground(colorCyan)) + + brandStyle = decorated(lipgloss.NewStyle().Foreground(colorBlue).Bold(true)) + brandAccentStyle = decorated(lipgloss.NewStyle().Foreground(colorBlue)) + + selectionIndicatorStyle = decorated(lipgloss.NewStyle().Foreground(colorWhite)) + sectionTitleStyle = faintStyle + statusBarStyle = decorated(lipgloss.NewStyle().Foreground(colorWhite)) + waitingDotStyle = greenStyle + connectingDotStyle = yellowStyle + dividerStyle = decorated(lipgloss.NewStyle().Foreground(colorFaint)) + + successStatusStyle = decorated(lipgloss.NewStyle().Foreground(colorGreen)) + errorStatusStyle = decorated(lipgloss.NewStyle().Foreground(colorRed)) + warningStatusStyle = decorated(lipgloss.NewStyle().Foreground(colorYellow)) +} // ColorizeStatus returns a styled status code string func ColorizeStatus(status int) string { diff --git a/pkg/listen/tui/styles_test.go b/pkg/listen/tui/styles_test.go new file mode 100644 index 00000000..dc5f040d --- /dev/null +++ b/pkg/listen/tui/styles_test.go @@ -0,0 +1,111 @@ +package tui + +import ( + "regexp" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// forceColorProfile makes lipgloss emit escape sequences under `go test`, which +// has no terminal and would otherwise render everything plain — hiding the very +// bytes #404 is about. +func forceColorProfile(t *testing.T) { + t.Helper() + previous := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(previous) }) +} + +// sgrPattern matches an SGR (colour/bold/faint) escape sequence. +var sgrPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// renderEveryTUISurface draws the frames a listen session actually produces: +// the connecting frame, the connected frame, an event list, and the details +// view. Counting SGR bytes across all of them is the measurement #404 reported. +func renderEveryTUISurface(t *testing.T) string { + t.Helper() + + m := newConnectionTestModel(t) + out := m.View() // connecting frame + + updated, _ := m.Update(ConnectedMsg{}) + m = updated.(Model) + out += m.View() // connected, waiting for events + + m.AddEvent(EventInfo{ + ID: "evt_1", + Success: true, + Status: 200, + ResponseStatus: 200, + ResponseHeaders: map[string][]string{"content-type": {"application/json"}}, + ResponseBody: `{"ok":true}`, + ResponseDuration: time.Millisecond, + Time: time.Date(2026, time.July, 21, 18, 18, 38, 0, time.UTC), + LogLine: "2026-07-21 18:18:38 [" + ColorizeStatus(200) + "] POST http://localhost:3030/", + Data: &websocket.Attempt{Body: websocket.AttemptBody{ + Path: "/webhooks", + Request: websocket.AttemptRequest{Method: "POST", Headers: []byte(`{"x-test":"v"}`), DataString: `{"a":1}`}, + }}, + }) + m.headerCollapsed = false + out += m.View() // header, event list and status bar + + m.serverHealthChecked = true + m.serverHealthy = false + out += m.renderConnectionInfo() // unhealthy-server warning + + m.setDetailsContent(m.GetSelectedEvent()) + m.showingDetails = true + out += m.renderDetailsView() + + failed, _ := m.Update(ConnectionFailedMsg{Err: assertErr("refused")}) + out += failed.(Model).View() + + return out +} + +type assertErr string + +func (e assertErr) Error() string { return string(e) } + +// TestSetColorEnabledFalseRemovesEveryEscapeSequence is the regression test for +// #404. --color off is applied in config.InitConfig, which only ever reached +// pkg/ansi; the TUI draws with lipgloss, so a controlling-pty run with the flag +// set still emitted 48 SGR sequences. Colour is decoration the user switched +// off, not something one output mode gets to opt out of. +func TestSetColorEnabledFalseRemovesEveryEscapeSequence(t *testing.T) { + forceColorProfile(t) + t.Cleanup(func() { SetColorEnabled(true) }) + + SetColorEnabled(true) + colored := renderEveryTUISurface(t) + require.NotEmpty(t, sgrPattern.FindAllString(colored, -1), + "with colour on the TUI must still be decorated, or this test proves nothing") + + SetColorEnabled(false) + plain := renderEveryTUISurface(t) + + assert.Empty(t, sgrPattern.FindAllString(plain, -1), + "--color off must reach the interactive renderer, not just the compact one") +} + +// TestSetColorEnabledKeepsTheWords checks that switching colour off removes only +// decoration. Stripping the status text along with the escape codes would +// reintroduce #399 for anyone running with NO_COLOR set. +func TestSetColorEnabledKeepsTheWords(t *testing.T) { + forceColorProfile(t) + t.Cleanup(func() { SetColorEnabled(true) }) + + SetColorEnabled(false) + m := newConnectionTestModel(t) + view := m.View() + + assert.Contains(t, view, "Listening on 1 source • 1 connection") + assert.Contains(t, lastLine(view), connectingLabel) +} diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index 2711971c..581f7a3c 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -50,14 +50,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ConnectingMsg: m.isConnected = false + m.connState = connConnecting return m, nil case ConnectedMsg: m.isConnected = true + m.connState = connConnected + m.connErr = nil + m.connAttempts = 0 return m, nil case DisconnectedMsg: m.isConnected = false + // A drop after a successful connect is a reconnect; a drop before one is + // a failed attempt at the initial connection. Reporting the second as + // "Reconnecting" would claim a connection the CLI never had. + if m.connState == connConnected || m.connState == connReconnecting { + m.connState = connReconnecting + } else { + m.connState = connConnecting + m.connAttempts++ + } + return m, nil + + case ConnectionFailedMsg: + m.isConnected = false + m.connState = connFailed + m.connErr = msg.Err return m, nil case ServerHealthMsg: diff --git a/pkg/listen/tui/view.go b/pkg/listen/tui/view.go index 7fd15ef8..59a873ed 100644 --- a/pkg/listen/tui/view.go +++ b/pkg/listen/tui/view.go @@ -6,8 +6,24 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/summary" ) +// Connection-state labels. These are the words the status bar shows, and they +// are the whole point of #399: every mode must say what state it is in, so +// readiness is affirmative text rather than something a user has to infer from +// a line that is missing. +const ( + connectingLabel = "Connecting…" + connectedLabel = "Connected." + reconnectingLabel = "Reconnecting…" + failedLabel = "Connection failed" +) + +// connStatusBudgetWithEvents caps the connection state once the event summary +// shares the bar, so the two together still fit on one line. +const connStatusBudgetWithEvents = 24 + // View renders the TUI with fixed header and scrollable event list func (m Model) View() string { if !m.ready || !m.viewportReady { @@ -65,16 +81,11 @@ func (m Model) View() string { // m.height is total LINES on screen // We need: header lines + viewport lines + divider (1) + status (1) = m.height - var viewportHeight int - if m.isConnected { - // When connected, always show status bar (for server health indicator) - // Total lines: header + viewport + divider + status - viewportHeight = m.height - headerHeight - 2 - } else { - // When not connected, no status bar - // Total lines: header + viewport - viewportHeight = m.height - headerHeight - } + // The status bar is drawn on every frame, connected or not. It used to appear + // only once connected, which is what made #399 so quiet: a session that never + // connected rendered the complete layout with the status line simply absent. + // Total lines: header + viewport + divider + status. + viewportHeight := m.height - headerHeight - 2 if viewportHeight < 1 { viewportHeight = 1 @@ -93,35 +104,34 @@ func (m Model) View() string { viewportOutput := m.viewport.View() output += viewportOutput - if m.isConnected { - // When connected, always show status bar (includes server health indicator) - // Ensure we have a newline before divider if viewport doesn't end with one - if !strings.HasSuffix(viewportOutput, "\n") { - output += "\n" - } + // Ensure we have a newline before divider if viewport doesn't end with one + if !strings.HasSuffix(viewportOutput, "\n") { + output += "\n" + } - // Divider line - divider := strings.Repeat("─", m.width) - output += dividerStyle.Render(divider) + "\n" + // Divider line + divider := strings.Repeat("─", m.width) + output += dividerStyle.Render(divider) + "\n" - // Status bar - LAST line, no trailing newline - output += m.renderStatusBar() - } else { - // Remove any trailing newline if no status bar - output = strings.TrimSuffix(output, "\n") - } + // Status bar - LAST line, no trailing newline + output += m.renderStatusBar() return output } -// renderConnectingStatus shows the connecting animation +// renderConnectingStatus shows the pending/failed connection state in the body +// of the view, using the same words as the status bar. func (m Model) renderConnectingStatus() string { dot := "●" if m.waitingFrameToggle { dot = "○" } - return connectingDotStyle.Render(dot) + " Connecting..." + if m.connState == connFailed { + return redStyle.Render("●") + " " + m.connectionStateText() + } + + return connectingDotStyle.Render(dot) + " " + m.connectionStateText() } // renderWaitingStatus shows the waiting animation before first event @@ -131,7 +141,60 @@ func (m Model) renderWaitingStatus() string { dot = "○" } - return waitingDotStyle.Render(dot) + " Connected. Waiting for events..." + return waitingDotStyle.Render(dot) + " " + connectedLabel + " Waiting for events..." +} + +// connectionStateText is the plain-text connection state, without decoration. +// Every mode reports readiness with these words; nothing about the state is left +// to be inferred from an absent line (#399). +func (m Model) connectionStateText() string { + switch m.connState { + case connConnected: + return connectedLabel + case connReconnecting: + return reconnectingLabel + case connFailed: + if m.connErr != nil { + return failedLabel + ": " + m.connErr.Error() + } + return failedLabel + default: + if m.connAttempts > 0 { + return fmt.Sprintf("%s (attempt %d)", connectingLabel, m.connAttempts+1) + } + return connectingLabel + } +} + +// renderConnectionStatus is connectionStateText with its state dot, for the +// status bar. budget is how many characters the text may use before the bar +// wraps onto a second line and pushes the frame past the terminal height; a +// failure reason can easily be longer than the window is wide. +func (m Model) renderConnectionStatus(budget int) string { + switch m.connState { + case connConnected: + return greenStyle.Render("●") + " " + connectedLabel + case connFailed: + return redStyle.Render("●") + " " + truncate(m.connectionStateText(), budget) + default: + return yellowStyle.Render("●") + " " + truncate(m.connectionStateText(), budget) + } +} + +// truncate shortens text to at most limit characters, ending with an ellipsis. +// It counts runes, not bytes, so a multi-byte character is never cut in half. +func truncate(text string, limit int) string { + if limit < 1 { + return text + } + runes := []rune(text) + if len(runes) <= limit { + return text + } + if limit == 1 { + return "…" + } + return string(runes[:limit-1]) + "…" } // renderEventHistory renders all events with selection indicator on selected @@ -193,14 +256,22 @@ func (m Model) renderDetailsView() string { return output.String() } -// renderStatusBar renders the bottom status bar with keyboard shortcuts +// renderStatusBar renders the bottom status bar: the connection state first, +// then the selected-event summary and keyboard shortcuts. +// +// The connection state leads on every frame. Before #399 this bar existed only +// once connected, so the pending and failed states had no words at all. func (m Model) renderStatusBar() string { - // If no events yet, just show quit instruction + // If no events yet, just show the connection state and quit instruction selectedEvent := m.GetSelectedEvent() if selectedEvent == nil { - return statusBarStyle.Width(m.width).Render("[q] Quit") + const tail = " • [q] Quit" + connStatus := m.renderConnectionStatus(m.width - len(tail) - 2) + return statusBarStyle.Width(m.width).Render(connStatus + tail) } + connStatus := m.renderConnectionStatus(connStatusBudgetWithEvents) + // Determine width-based verbosity // Threshold chosen to show full text only when it fits without wrapping // Full text requires ~105 chars with some padding @@ -256,7 +327,7 @@ func (m Model) renderStatusBar() string { } } - return statusBarStyle.Width(m.width).Render(eventStatusMsg) + return statusBarStyle.Width(m.width).Render(connStatus + " " + eventStatusMsg) } // FormatEventLog formats an event into a log line matching the current style @@ -319,16 +390,7 @@ func (m Model) renderConnectionInfo() string { numConnections = len(m.cfg.Connections) } - sourcesText := fmt.Sprintf("%d source", numSources) - if numSources != 1 { - sourcesText += "s" - } - connectionsText := fmt.Sprintf("%d connection", numConnections) - if numConnections != 1 { - connectionsText += "s" - } - - listeningTitle := fmt.Sprintf("Listening on %s • %s • [i] Collapse", sourcesText, connectionsText) + listeningTitle := summary.Listening(numSources, numConnections) + " • [i] Collapse" s.WriteString(faintStyle.Render(listeningTitle)) s.WriteString("\n\n") @@ -493,19 +555,8 @@ func (m Model) renderCompactHeader() string { } // Compact summary with toggle hint - sourcesText := fmt.Sprintf("%d source", numSources) - if numSources != 1 { - sourcesText += "s" - } - connectionsText := fmt.Sprintf("%d connection", numConnections) - if numConnections != 1 { - connectionsText += "s" - } - - summary := fmt.Sprintf("Listening on %s • %s • [i] Expand", - sourcesText, - connectionsText) - s.WriteString(faintStyle.Render(summary)) + collapsedTitle := summary.Listening(numSources, numConnections) + " • [i] Expand" + s.WriteString(faintStyle.Render(collapsedTitle)) s.WriteString("\n") // Show server health warning if unhealthy (ensure it's always visible even when collapsed) diff --git a/pkg/listen/tui/view_test.go b/pkg/listen/tui/view_test.go new file mode 100644 index 00000000..e3252a0c --- /dev/null +++ b/pkg/listen/tui/view_test.go @@ -0,0 +1,197 @@ +package tui + +import ( + "errors" + "fmt" + "net/url" + "strings" + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newConnectionTestModel builds a Model wired to one source and one connection, +// sized like a real terminal and past the first WindowSizeMsg. +func newConnectionTestModel(t *testing.T) Model { + t.Helper() + + targetURL, err := url.Parse("http://localhost:3030") + require.NoError(t, err) + + fullName := "src -> dest" + source := &hookdeck.Source{ID: "src_1", Name: "my-source", URL: "https://hkdk.events/src_1"} + connection := &hookdeck.Connection{ + ID: "web_1", + FullName: &fullName, + Source: source, + Destination: &hookdeck.Destination{ID: "des_1", Type: "CLI", Config: map[string]interface{}{"path": "/"}}, + } + + m := NewModel(&Config{ + TargetURL: targetURL, + Sources: []*hookdeck.Source{source}, + Connections: []*hookdeck.Connection{connection}, + DashboardBaseURL: "https://dashboard.hookdeck.com", + ProjectID: "tm_1", + }) + m.width = 120 + m.height = 30 + m.ready = true + m.viewportReady = true + + return m +} + +// lastLine returns the status bar: View() writes it last with no trailing newline. +func lastLine(view string) string { + lines := strings.Split(view, "\n") + return lines[len(lines)-1] +} + +// TestStatusBarAlwaysReportsConnectionState is the regression test for #399. +// +// The TUI used to render the complete layout — brand header, "Listening on …", +// "Requests to →", "Forwards to →" — with no status bar at all until the +// websocket connected. A run that never connected therefore looked exactly like +// a working one for the whole 40-second attempt budget, the only difference +// being a line that was absent. #376 fixed the same bug in the non-TTY +// renderers; this is the interactive half of it. +func TestStatusBarAlwaysReportsConnectionState(t *testing.T) { + t.Run("the first frame says Connecting, before anything is connected", func(t *testing.T) { + m := newConnectionTestModel(t) + + view := m.View() + + require.NotEmpty(t, view) + assert.Contains(t, view, "Listening on 1 source • 1 connection", + "the header still renders; that was never the problem") + assert.Contains(t, lastLine(view), connectingLabel, + "the status bar must state the pending state on the very first frame") + assert.NotContains(t, lastLine(view), connectedLabel) + }) + + t.Run("a connect replaces it with Connected", func(t *testing.T) { + m := newConnectionTestModel(t) + + updated, _ := m.Update(ConnectedMsg{}) + view := updated.(Model).View() + + assert.Contains(t, lastLine(view), connectedLabel) + assert.NotContains(t, lastLine(view), connectingLabel) + }) + + t.Run("failed attempts before the first connect are counted, not hidden", func(t *testing.T) { + m := newConnectionTestModel(t) + + updated, _ := m.Update(DisconnectedMsg{}) + updated, _ = updated.(Model).Update(DisconnectedMsg{}) + view := updated.(Model).View() + + assert.Contains(t, lastLine(view), "Connecting… (attempt 3)", + "retrying is progress the user can see, not silence") + assert.NotContains(t, lastLine(view), reconnectingLabel, + "the CLI never connected, so it cannot claim to be reconnecting") + }) + + t.Run("a drop after connecting reports reconnecting", func(t *testing.T) { + m := newConnectionTestModel(t) + + updated, _ := m.Update(ConnectedMsg{}) + updated, _ = updated.(Model).Update(DisconnectedMsg{}) + view := updated.(Model).View() + + assert.Contains(t, lastLine(view), reconnectingLabel) + }) + + t.Run("giving up shows a visible failure state with the reason", func(t *testing.T) { + m := newConnectionTestModel(t) + + updated, _ := m.Update(ConnectionFailedMsg{Err: errors.New("dial tcp 127.0.0.1:9: connect: connection refused")}) + view := updated.(Model).View() + + assert.Contains(t, lastLine(view), failedLabel) + assert.Contains(t, lastLine(view), "connection refused", + "the failure state must carry the reason, not just the fact") + }) + + t.Run("the connection state survives events arriving", func(t *testing.T) { + m := newConnectionTestModel(t) + updated, _ := m.Update(ConnectedMsg{}) + m = updated.(Model) + m.AddEvent(EventInfo{ID: "evt_1", Success: true, Status: 200, LogLine: "200 POST /"}) + + view := m.View() + + assert.Contains(t, lastLine(view), connectedLabel, + "readiness must stay affirmative once the event list takes over the bar") + }) +} + +// TestConnectingStateIsNotInferredFromAbsentText pins the shape of the bug +// rather than the wording: whatever state the model is in, the last line of the +// view must carry words about the connection. An empty status bar is the failure +// mode #399 reported. +func TestConnectingStateIsNotInferredFromAbsentText(t *testing.T) { + states := []struct { + name string + apply func(Model) Model + }{ + {"initial", func(m Model) Model { return m }}, + {"connected", func(m Model) Model { u, _ := m.Update(ConnectedMsg{}); return u.(Model) }}, + {"reconnecting", func(m Model) Model { + u, _ := m.Update(ConnectedMsg{}) + u, _ = u.(Model).Update(DisconnectedMsg{}) + return u.(Model) + }}, + {"failed", func(m Model) Model { + u, _ := m.Update(ConnectionFailedMsg{Err: errors.New("boom")}) + return u.(Model) + }}, + } + + for _, state := range states { + t.Run(state.name, func(t *testing.T) { + view := state.apply(newConnectionTestModel(t)).View() + assert.NotEmpty(t, strings.TrimSpace(lastLine(view)), + "the status bar must never be blank: absence is not a signal") + }) + } +} + +// TestHeaderSummaryMatchesCompactRenderer guards the other half of #402 — the +// interactive header and the compact banner are built from the same helper, so +// they cannot drift apart again. +func TestHeaderSummaryMatchesCompactRenderer(t *testing.T) { + m := newConnectionTestModel(t) + + assert.Contains(t, m.renderConnectionInfo(), "Listening on 1 source • 1 connection • [i] Collapse") + + m.headerCollapsed = true + assert.Contains(t, m.renderConnectionInfo(), "Listening on 1 source • 1 connection • [i] Expand") +} + +// TestStatusBarStaysOneLine checks that a long failure reason is trimmed rather +// than wrapped. A wrapped status bar makes the frame taller than the terminal, +// which scrolls the header out of view exactly when the user needs to read it. +func TestStatusBarStaysOneLine(t *testing.T) { + longReason := "dial tcp 127.0.0.1:9: connect: connection refused " + strings.Repeat("and more detail ", 20) + + for _, width := range []int{40, 80, 120} { + t.Run(fmt.Sprintf("width %d", width), func(t *testing.T) { + m := newConnectionTestModel(t) + m.width = width + updated, _ := m.Update(ConnectionFailedMsg{Err: errors.New(longReason)}) + m = updated.(Model) + + bar := m.renderStatusBar() + + assert.NotContains(t, bar, "\n", "the status bar is one line") + assert.LessOrEqual(t, len([]rune(bar)), width, + "the bar must fit the terminal so it does not wrap") + assert.Contains(t, bar, "Connection failed", + "trimming the reason must not trim away the state itself") + }) + } +} diff --git a/test/acceptance/listen_tty_test.go b/test/acceptance/listen_tty_test.go new file mode 100644 index 00000000..ac2bbde9 --- /dev/null +++ b/test/acceptance/listen_tty_test.go @@ -0,0 +1,204 @@ +//go:build listen + +package acceptance + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/creack/pty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ttySGRPattern matches an SGR (colour/bold/faint) escape sequence. +var ttySGRPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// startListenOnAControllingTTY runs `hookdeck listen` with a real pty as its +// controlling terminal, so the interactive renderer starts for real, and returns +// everything it drew during the window. +// +// creack/pty sets Setsid and Setctty, which is what makes this a *controlling* +// terminal rather than just a pipe that happens to pass term.IsTerminal. Earlier +// attempts to capture the TUI with `script -q` produced inconsistent output and +// sent people chasing failures that were not there. +func startListenOnAControllingTTY(t *testing.T, cli *CLIRunner, window time.Duration, extraArgs ...string) string { + t.Helper() + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err, "Failed to get project root") + + binary := filepath.Join(projectRoot, "hookdeck-listen-tty-"+generateTimestamp()) + buildCmd := exec.Command("go", "build", "-o", binary, ".") + buildCmd.Dir = projectRoot + require.NoError(t, buildCmd.Run(), "failed to build CLI binary") + t.Cleanup(func() { _ = os.Remove(binary) }) + + cmd := exec.Command(binary, extraArgs...) + cmd.Dir = projectRoot + + env := os.Environ() + if cli.configPath != "" { + env = appendEnvOverride(env, "HOOKDECK_CONFIG_FILE", cli.configPath) + } + // A TUI-sized window, so the whole frame has room to render. + env = appendEnvOverride(env, "TERM", "xterm-256color") + cmd.Env = env + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 45, Cols: 120}) + require.NoError(t, err, "listen should start on a pty") + + t.Cleanup(func() { + _ = ptmx.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }) + + var out strings.Builder + deadline := time.Now().Add(window) + buf := make([]byte, 32*1024) + for time.Now().Before(deadline) { + _ = ptmx.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, readErr := ptmx.Read(buf) + if n > 0 { + out.Write(buf[:n]) + } + if readErr != nil && !os.IsTimeout(readErr) { + break + } + } + + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + + return out.String() +} + +// TestListenInteractiveShowsPendingStateBeforeItIsConnected is the acceptance +// test for #399, run the way the issue was reported: a real terminal and a +// websocket base URL nothing is listening on. +// +// The TUI used to render its complete layout immediately — brand header, +// "Listening on …", "Requests to →", "Forwards to →" — with an empty status bar, +// and stay that way for the whole 40-second attempt budget before tearing the +// alt-screen down and printing an error. That is #376 inverted: there, readiness +// was never announced; here, the absence of a line was the only thing separating +// "connected" from "not connected", and absence is not a signal a user reads. +func TestListenInteractiveShowsPendingStateBeforeItIsConnected(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + var conn Connection + require.NoError(t, cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", "test-tty-pending-conn-"+timestamp, + "--source-name", "test-tty-pending-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-tty-pending-dst-"+timestamp, + "--destination-type", "CLI", + "--destination-cli-path", "/", + )) + require.NotEmpty(t, conn.ID) + t.Cleanup(func() { deleteConnection(t, cli, conn.ID) }) + + // Port 9 (discard) is closed for websockets, so the CLI connects to the API, + // renders the TUI, and then never establishes the tunnel. + out := startListenOnAControllingTTY(t, cli, 20*time.Second, + "listen", "3030", "test-tty-pending-"+timestamp, + "--ws-base", "ws://127.0.0.1:9") + + require.Contains(t, out, "Listening on", + "the TUI should have rendered; without that this test proves nothing") + + assert.Contains(t, out, "Connecting…", + "an unconnected session must say so, not render a live-looking layout (#399)") + assert.NotContains(t, out, "Connected.", + "the CLI never connected, so it must never claim it did") +} + +// TestListenInteractiveAnnouncesConnectedOnATTY is the other half of #399: the +// pending state has to be *replaced*, not merely added. Readiness stays +// affirmative in interactive mode exactly as #376 made it affirmative everywhere +// else. +func TestListenInteractiveAnnouncesConnectedOnATTY(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + var conn Connection + require.NoError(t, cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", "test-tty-ready-conn-"+timestamp, + "--source-name", "test-tty-ready-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-tty-ready-dst-"+timestamp, + "--destination-type", "CLI", + "--destination-cli-path", "/", + )) + require.NotEmpty(t, conn.ID) + t.Cleanup(func() { deleteConnection(t, cli, conn.ID) }) + + out := startListenOnAControllingTTY(t, cli, 25*time.Second, + "listen", "3030", "test-tty-ready-"+timestamp) + + require.Contains(t, out, "Listening on", + "the TUI should have rendered; without that this test proves nothing") + assert.Contains(t, out, "Connected.", + "a connected session must say so in the status bar (#399)") +} + +// TestListenInteractiveHonoursColorOff is the acceptance test for #404. --color +// off is applied in config.InitConfig, which only ever reached pkg/ansi; the TUI +// draws with lipgloss, so a controlling-pty run with the flag set still emitted +// 48 SGR sequences. The control run asserts the default is still decorated, so a +// TUI that failed to start could not make this pass by drawing nothing. +func TestListenInteractiveHonoursColorOff(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + var conn Connection + require.NoError(t, cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", "test-tty-color-conn-"+timestamp, + "--source-name", "test-tty-color-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-tty-color-dst-"+timestamp, + "--destination-type", "CLI", + "--destination-cli-path", "/", + )) + require.NotEmpty(t, conn.ID) + t.Cleanup(func() { deleteConnection(t, cli, conn.ID) }) + + sourceName := "test-tty-color-" + timestamp + + colored := startListenOnAControllingTTY(t, cli, 20*time.Second, "listen", "3030", sourceName) + require.Contains(t, colored, "Listening on", "the control run should have rendered the TUI") + require.NotEmpty(t, ttySGRPattern.FindAllString(colored, -1), + "the default TUI is decorated; without that this test proves nothing") + + plain := startListenOnAControllingTTY(t, cli, 20*time.Second, + "listen", "3030", sourceName, "--color", "off") + + require.Contains(t, plain, "Listening on", + "--color off must still render the TUI, just without decoration") + assert.Empty(t, ttySGRPattern.FindAllString(plain, -1), + "--color off must reach the interactive renderer, not just the compact one (#404)") +} From 1e0a3fb53d5357dbed2d005f6cf197278f06e564 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 21:18:18 +0100 Subject: [PATCH 11/16] fix: only send a destination type the user actually asked for The upsert builder asserted the resolved type back onto the request. The type resolution is needed -- it decides whether --url is even a valid field for this destination -- but sending it makes the command a read-modify-write: if the destination's type changes between the lookup and the PUT, we silently revert it. Omitting the field leaves the stored type alone. Verified against the live API: an upsert carrying a config and no type field is accepted, applies the change, and keeps the stored type. This also makes upsert agree with update, which already asserted exactly this rule ("resolving the stored type must not start sending a type the user did not pass") -- the two builders had opposite behaviour for the same situation. An explicit --type is still sent, and is covered by a new case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/destination_stored_state_test.go | 15 +++++++++++++-- pkg/cmd/destination_upsert.go | 13 ++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/destination_stored_state_test.go b/pkg/cmd/destination_stored_state_test.go index f13aef88..0f97447e 100644 --- a/pkg/cmd/destination_stored_state_test.go +++ b/pkg/cmd/destination_stored_state_test.go @@ -676,7 +676,9 @@ func TestDestinationUpsertAppliesTypeSpecificFlagsWithoutType(t *testing.T) { req, err := dc.buildUpsertRequest(context.Background(), client) require.NoError(t, err) assert.Equal(t, "https://new.example.com/hook", req.Config["url"]) - assert.Equal(t, "HTTP", req.Type, "a PUT carrying a config has to say which kind of destination it is") + assert.Equal(t, "", req.Type, + "resolving the stored type must not start sending a type the user did not pass: "+ + "asserting it back would revert a type changed between the lookup and this PUT") }) t.Run("--cli-path alone reaches the request", func(t *testing.T) { @@ -686,7 +688,16 @@ func TestDestinationUpsertAppliesTypeSpecificFlagsWithoutType(t *testing.T) { req, err := dc.buildUpsertRequest(context.Background(), client) require.NoError(t, err) assert.Equal(t, "/webhooks", req.Config["path"]) - assert.Equal(t, "CLI", req.Type) + assert.Equal(t, "", req.Type) + }) + + t.Run("an explicit --type is still sent", func(t *testing.T) { + client, _ := storedDestServer(t, storedHTTPDestWithOverrides(), 0) + dc := &destinationUpsertCmd{name: "web", destType: "HTTP", url: "https://new.example.com/hook"} + + req, err := dc.buildUpsertRequest(context.Background(), client) + require.NoError(t, err) + assert.Equal(t, "HTTP", req.Type, "what the user asked for is still honoured") }) t.Run("a create with no stored type to resolve says so", func(t *testing.T) { diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index e31fb2b3..cb6c9dbf 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -207,13 +207,12 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * if dc.description != "" { req.Description = &dc.description } - // The resolved type goes on the request as well. This is a PUT against the - // collection, so a body carrying a config needs to say what kind of - // destination it is — and applyStoredDestinationConfig already adopts the - // stored type on the path where no config is sent at all. Resolving it never - // changes the type: it is either the one the user passed or the one the - // destination already has. - if rt != "" { + // Only send a type the user actually asked for. The resolved type is used to + // decide which config fields are valid, but asserting it back on the request + // would make this a read-modify-write: if the destination's type changed + // between the lookup and this PUT, we would silently revert it. The API keeps + // the stored type when the field is absent, verified against the live API. + if dc.destType != "" { req.Type = rt } if len(config) > 0 { From afdf5c7544ed39ff53d1c392ee53f199bf881e84 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 21:20:06 +0100 Subject: [PATCH 12/16] fix(mcp): stop the tools advertising and dropping what the API will not honour Found by driving the MCP server over JSON-RPC against the live API. The shape throughout: MCP flattens several CLI subcommands into one tool with one flat schema, and the per-subcommand precision the CLI has is lost. hookdeck_requests accepted delivery_group on action "list" and the API silently ignored it -- a bogus value returned rows byte-identical to the unfiltered baseline, while every other filter narrows to zero. That is the same silent-wrong-answer hazard the metrics tool already guards against, unguarded one tool over, on the feature this release ships for. Arguments an action does not support are now refused, for hookdeck_requests and hookdeck_events alike. ignored_events was passing nil for limit/next/prev, so pagination was dropped. Dimensions received no client-side gating at all, unlike filters, so known-bad combinations fell through to raw 422s -- including the delivery_group dimension, which requires a destination_id filter. Gated per route from the shared matrix, in the CLI as well as MCP. The metrics schema advertised four "common" measures, three of which fail on most routes, and gave measures, dimensions and status no per-action values at all -- the three parameters that decide whether a call succeeds. All three are now accurate per action and derived from shared constants, so the CLI's hand-maintained lists and the MCP schema cannot drift again. 422 bodies surfaced verbatim with internal fields, burying the useful message. Also applies the #407 cross-route guard to the MCP routing, which the CLI-side change could not reach. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/metrics.go | 6 + pkg/cmd/metrics_attempts.go | 7 +- pkg/cmd/metrics_dimensions_test.go | 91 +++++++ pkg/cmd/metrics_events.go | 17 +- pkg/cmd/metrics_requests.go | 7 +- pkg/cmd/metrics_transformations.go | 9 +- pkg/gateway/mcp/tool_actions.go | 133 ++++++++++ pkg/gateway/mcp/tool_actions_test.go | 241 ++++++++++++++++++ pkg/gateway/mcp/tool_events.go | 11 +- pkg/gateway/mcp/tool_help.go | 40 ++- pkg/gateway/mcp/tool_metrics.go | 39 ++- .../mcp/tool_metrics_dimensions_test.go | 175 +++++++++++++ pkg/gateway/mcp/tool_metrics_measures_test.go | 99 +++++++ pkg/gateway/mcp/tool_metrics_schema_test.go | 160 ++++++++++++ pkg/gateway/mcp/tool_requests.go | 21 +- pkg/gateway/mcp/tools.go | 149 +++++++---- pkg/hookdeck/client.go | 44 +++- pkg/hookdeck/client_error_message_test.go | 76 ++++++ pkg/hookdeck/metrics_dimensions_test.go | 176 +++++++++++++ pkg/hookdeck/metrics_filters.go | 224 +++++++++++++++- 20 files changed, 1627 insertions(+), 98 deletions(-) create mode 100644 pkg/cmd/metrics_dimensions_test.go create mode 100644 pkg/gateway/mcp/tool_actions.go create mode 100644 pkg/gateway/mcp/tool_actions_test.go create mode 100644 pkg/gateway/mcp/tool_metrics_dimensions_test.go create mode 100644 pkg/gateway/mcp/tool_metrics_schema_test.go create mode 100644 pkg/hookdeck/client_error_message_test.go create mode 100644 pkg/hookdeck/metrics_dimensions_test.go diff --git a/pkg/cmd/metrics.go b/pkg/cmd/metrics.go index 1b2e5a39..871b7b5b 100644 --- a/pkg/cmd/metrics.go +++ b/pkg/cmd/metrics.go @@ -107,6 +107,12 @@ func rejectUnsupportedFilters(params hookdeck.MetricsQueryParams, allowed hookde return hookdeck.RejectUnsupportedFilters(params, allowed, route, hookdeck.CLIFilterNames) } +// rejectUnsupportedDimensions is the dimension counterpart, reading the same +// shared matrix as the MCP layer so the two cannot drift. +func rejectUnsupportedDimensions(params hookdeck.MetricsQueryParams, allowed []string, route string) error { + return hookdeck.RejectUnsupportedDimensions(params, allowed, route, hookdeck.CLIFilterNames, "--dimensions") +} + // metricsParamsFromFlags builds hookdeck.MetricsQueryParams from common flags. // Measures and dimensions are split from comma-separated strings. func metricsParamsFromFlags(f *metricsCommonFlags) hookdeck.MetricsQueryParams { diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go index b1a5c52f..8c7b19f9 100644 --- a/pkg/cmd/metrics_attempts.go +++ b/pkg/cmd/metrics_attempts.go @@ -8,8 +8,6 @@ import ( "github.com/spf13/cobra" ) -const metricsAttemptsMeasures = "count, successful_count, failed_count, delivered_count, error_rate, response_latency_avg, response_latency_max, response_latency_p95, response_latency_p99, delivery_latency_avg" - type metricsAttemptsCmd struct { cmd *cobra.Command flags metricsCommonFlags @@ -21,7 +19,7 @@ func newMetricsAttemptsCmd() *metricsAttemptsCmd { Use: "attempts", Args: cobra.NoArgs, Short: ShortBeta("Query attempt metrics"), - Long: LongBeta(`Query metrics for delivery attempts (latency, success/failure). Measures: ` + metricsAttemptsMeasures + `.`), + Long: LongBeta(`Query metrics for delivery attempts (latency, success/failure). Measures: ` + hookdeck.AttemptMetricsMeasures + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters, hookdeck.AttemptMetricsDimensions, hookdeck.AttemptStatusValues) @@ -33,6 +31,9 @@ func (c *metricsAttemptsCmd) runE(cmd *cobra.Command, args []string) error { return err } params := metricsParamsFromFlags(&c.flags) + if err := rejectUnsupportedDimensions(params, hookdeck.AttemptMetricsDimensionValues, "attempt metrics"); err != nil { + return err + } data, err := Config.GetAPIClient().QueryAttemptMetrics(context.Background(), params) if err != nil { return fmt.Errorf("query attempt metrics: %w", err) diff --git a/pkg/cmd/metrics_dimensions_test.go b/pkg/cmd/metrics_dimensions_test.go new file mode 100644 index 00000000..697f8d1e --- /dev/null +++ b/pkg/cmd/metrics_dimensions_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestEventDimensionsAreGatedPerRoute is the CLI half of the dimension gate. +// +// `metrics events` fans out over four endpoints and --help advertises the +// union, so the route has to narrow it - exactly as it already does for +// filters. Both layers call the same helper over the same matrix, which is what +// stopped the filter fix from drifting and should stop this one too. +func TestEventDimensionsAreGatedPerRoute(t *testing.T) { + tests := []struct { + name string + params hookdeck.MetricsQueryParams + contains []string + }{ + { + name: "pending groups by destination only", + params: hookdeck.MetricsQueryParams{Measures: []string{"pending"}, Dimensions: []string{"status"}}, + contains: []string{"--dimensions", "status", "pending event metrics", "destination_id"}, + }, + { + name: "queue depth has no status dimension", + params: hookdeck.MetricsQueryParams{Measures: []string{"queue_depth"}, Dimensions: []string{"status"}}, + contains: []string{"queue depth metrics"}, + }, + { + name: "per-issue route has a narrower set", + params: hookdeck.MetricsQueryParams{Measures: []string{"count"}, Dimensions: []string{"issue_id", "status"}, IssueID: "iss_1"}, + contains: []string{"per-issue event metrics", "status"}, + }, + { + name: "delivery_group grouping needs a destination filter", + params: hookdeck.MetricsQueryParams{Measures: []string{"count"}, Dimensions: []string{"delivery_group"}}, + contains: []string{"delivery_group", "--destination-id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, path, _ := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, tt.params) + require.Error(t, err, "a dimension the route does not define must not reach the API") + for _, want := range tt.contains { + assert.Contains(t, err.Error(), want) + } + assert.Empty(t, *path, "no request should have been sent") + }) + } +} + +// TestEventDimensionsTheRouteHonoursStillReachTheAPI is the other half: the +// combination that works against the live API must keep working. +func TestEventDimensionsTheRouteHonoursStillReachTheAPI(t *testing.T) { + client, path, query := routeCapture(t) + + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{ + Measures: []string{"count"}, + Dimensions: []string{"delivery_group"}, + DestinationID: "des_1", + }) + require.NoError(t, err) + + assert.Contains(t, *path, "/metrics/events") + assert.Equal(t, []string{"delivery_group"}, (*query)["dimensions[]"]) + assert.Equal(t, "des_1", query.Get("filters[destination_id]")) +} + +// TestMetricsHelpListsTheRealDimensions guards the --help text the user reads +// before composing a query. It claimed issue_id was a plain events dimension +// while omitting error_code, cli_id, attempts and response_status, so a user +// following it either grouped by something the route rejects or never learned +// about four dimensions that work. +func TestMetricsHelpListsTheRealDimensions(t *testing.T) { + long := newMetricsEventsCmd().cmd.Long + for _, dimension := range []string{"error_code", "cli_id", "attempts", "response_status"} { + assert.Contains(t, long, dimension, "events --help omits the %s dimension", dimension) + } + // The connection dimension is connection_id to a CLI user; webhook_id is + // the API's own spelling and is mapped on the way in. + assert.Contains(t, long, "connection_id") +} diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index abeb8abb..68a24782 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -9,8 +9,7 @@ import ( "github.com/spf13/cobra" ) -const metricsEventsMeasures = "count, successful_count, failed_count, scheduled_count, paused_count, error_rate, avg_attempts, scheduled_retry_count, pending, queue_depth, max_depth, max_age" -const metricsEventsDimensions = hookdeck.EventMetricsDimensions +var metricsEventsDimensions = hookdeck.EventMetricsDimensions type metricsEventsCmd struct { cmd *cobra.Command @@ -29,7 +28,7 @@ Requires --start and --end. When querying per-issue (e.g. --dimensions issue_id), --issue-id is required. -Measures: ` + metricsEventsMeasures + `. +Measures: ` + hookdeck.EventMetricsMeasures + `. Dimensions: ` + metricsEventsDimensions + `.`), RunE: c.runE, } @@ -80,6 +79,9 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if err := rejectUnsupportedFilters(params, hookdeck.QueueDepthRouteFilters, "queue depth metrics"); err != nil { return nil, err } + if err := rejectUnsupportedDimensions(params, hookdeck.QueueDepthRouteDimensions, "queue depth metrics"); err != nil { + return nil, err + } // The endpoint accepts max_depth and max_age only. "queue_depth" is our own // spelling for the route, advertised in --help, so translate it rather than // letting the API reject a measure we told the user to pass. @@ -95,6 +97,9 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if err := rejectUnsupportedFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (--measures pending)"); err != nil { return nil, err } + if err := rejectUnsupportedDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, "pending event metrics (--measures pending)"); err != nil { + return nil, err + } pendingParams := params pendingParams.Measures = []string{"count"} return client.QueryEventsPendingTimeseries(ctx, pendingParams) @@ -108,12 +113,18 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if err := rejectUnsupportedFilters(params, hookdeck.EventsByIssueRouteFilters, "per-issue event metrics"); err != nil { return nil, err } + if err := rejectUnsupportedDimensions(params, hookdeck.EventsByIssueRouteDimensions, "per-issue event metrics"); err != nil { + return nil, err + } return client.QueryEventsByIssue(ctx, params) } // 4. Default → QueryEventMetrics if err := rejectUnsupportedFilters(params, hookdeck.DefaultEventRouteFilters, "event metrics"); err != nil { return nil, err } + if err := rejectUnsupportedDimensions(params, hookdeck.DefaultEventRouteDimensions, "event metrics"); err != nil { + return nil, err + } return client.QueryEventMetrics(ctx, params) } diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go index 351a7769..68aa8c7d 100644 --- a/pkg/cmd/metrics_requests.go +++ b/pkg/cmd/metrics_requests.go @@ -8,8 +8,6 @@ import ( "github.com/spf13/cobra" ) -const metricsRequestsMeasures = "count, accepted_count, rejected_count, discarded_count, avg_events_per_request, avg_ignored_per_request" - type metricsRequestsCmd struct { cmd *cobra.Command flags metricsCommonFlags @@ -21,7 +19,7 @@ func newMetricsRequestsCmd() *metricsRequestsCmd { Use: "requests", Args: cobra.NoArgs, Short: ShortBeta("Query request metrics"), - Long: LongBeta(`Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`), + Long: LongBeta(`Query metrics for requests (acceptance, rejection, etc.). Measures: ` + hookdeck.RequestMetricsMeasures + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.RequestMetricsFilters, hookdeck.RequestMetricsDimensions, hookdeck.RequestStatusValues) @@ -33,6 +31,9 @@ func (c *metricsRequestsCmd) runE(cmd *cobra.Command, args []string) error { return err } params := metricsParamsFromFlags(&c.flags) + if err := rejectUnsupportedDimensions(params, hookdeck.RequestMetricsDimensionValues, "request metrics"); err != nil { + return err + } data, err := Config.GetAPIClient().QueryRequestMetrics(context.Background(), params) if err != nil { return fmt.Errorf("query request metrics: %w", err) diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go index d83abb3e..6ffbce05 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -8,8 +8,6 @@ import ( "github.com/spf13/cobra" ) -const metricsTransformationsMeasures = "count, successful_count, failed_count, error_rate, error_count, warn_count, info_count, debug_count" - type metricsTransformationsCmd struct { cmd *cobra.Command flags metricsCommonFlags @@ -21,10 +19,10 @@ func newMetricsTransformationsCmd() *metricsTransformationsCmd { Use: "transformations", Args: cobra.NoArgs, Short: ShortBeta("Query transformation metrics"), - Long: LongBeta(`Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`), + Long: LongBeta(`Query metrics for transformations. Measures: ` + hookdeck.TransformationMetricsMeasures + `.`), RunE: c.runE, } - addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters, hookdeck.TransformationMetricsDimensions, "") + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters, hookdeck.TransformationMetricsDimensions, hookdeck.TransformationStatusValues) return c } @@ -33,6 +31,9 @@ func (c *metricsTransformationsCmd) runE(cmd *cobra.Command, args []string) erro return err } params := metricsParamsFromFlags(&c.flags) + if err := rejectUnsupportedDimensions(params, hookdeck.TransformationMetricsDimensionValues, "transformation metrics"); err != nil { + return err + } data, err := Config.GetAPIClient().QueryTransformationMetrics(context.Background(), params) if err != nil { return fmt.Errorf("query transformation metrics: %w", err) diff --git a/pkg/gateway/mcp/tool_actions.go b/pkg/gateway/mcp/tool_actions.go new file mode 100644 index 00000000..718365fb --- /dev/null +++ b/pkg/gateway/mcp/tool_actions.go @@ -0,0 +1,133 @@ +package mcp + +import ( + "fmt" + "sort" + "strings" +) + +// Per-action argument matrices for the multi-action tools. +// +// The CLI keeps one flag set per subcommand, so `gateway request list` simply +// has no --delivery-group and `gateway request events` has no --source-id. The +// MCP layer flattens those subcommands into one tool with one flat schema, and +// that precision is lost: a filter meant for a sibling action is accepted, +// never forwarded, and the caller reads an unfiltered result as a filtered one. +// Every other filter narrows to zero rows on a bogus value, so nothing in the +// response reveals the drop. +// +// These maps re-impose the per-subcommand precision, naming the arguments each +// action actually forwards to the API. The refusal wording matches the metrics +// tool's, which has guarded the same hazard for a while. +var ( + // requestsActionArgs mirrors the flags of `hookdeck gateway request `. + // delivery_group is on events only - the /requests collection has no such + // query parameter, so on list the API answers with unfiltered rows. + requestsActionArgs = map[string][]string{ + "list": { + "id", "source_id", "status", "rejection_cause", "verified", + "created_after", "created_before", "ingested_after", "ingested_before", + "body", "headers", "parsed_query", "path", + "order_by", "dir", "limit", "next", "prev", + }, + "get": {"id"}, + "raw_body": {"id"}, + "events": {"id", "delivery_group", "limit", "next", "prev"}, + "ignored_events": {"id", "limit", "next", "prev"}, + } + + // eventsActionArgs mirrors the flags of `hookdeck gateway event `. + // get and raw_body address one event by id, so every list filter passed + // alongside them was dropped in silence. + eventsActionArgs = map[string][]string{ + "list": { + "id", "connection_id", "source_id", "destination_id", "delivery_group", + "status", "attempts", "issue_id", "error_code", "response_status", "cli_id", + "created_after", "created_before", "successful_after", "successful_before", + "last_attempt_after", "last_attempt_before", + "body", "headers", "parsed_query", "path", + "order_by", "dir", "limit", "next", "prev", + }, + "get": {"id"}, + "raw_body": {"id"}, + } +) + +// argIsSet reports whether the caller actually supplied a value. The handlers +// forward string and numeric arguments only when non-zero, so a key present +// with an empty value is not a dropped filter and must not be refused. +func argIsSet(v interface{}) bool { + switch val := v.(type) { + case nil: + return false + case string: + return val != "" + case float64: + return val != 0 + case []interface{}: + return len(val) > 0 + case map[string]interface{}: + return len(val) > 0 + default: + return true + } +} + +// rejectArgsUnsupportedByAction reports the first argument the tool declares but +// this action does not honour. +// +// Only arguments the tool's own schema advertises are policed: an unrecognised +// key is not a filter the caller expected to take effect, and MCP clients add +// their own. declared is the tool's schema property map. +func rejectArgsUnsupportedByAction(in input, tool, action string, byAction map[string][]string, declared map[string]prop) error { + supported, ok := byAction[action] + if !ok { + // An unknown action is the handler's own error to report. + return nil + } + allowed := make(map[string]bool, len(supported)) + for _, name := range supported { + allowed[name] = true + } + + names := make([]string, 0, len(in)) + for name := range in { + names = append(names, name) + } + // Deterministic so the same call always names the same argument first. + sort.Strings(names) + + for _, name := range names { + if name == "action" || allowed[name] || !argIsSet(in[name]) { + continue + } + if _, isDeclared := declared[name]; !isDeclared { + continue + } + return fmt.Errorf("%s is not supported by the %s action of %s; the API would ignore it and return unfiltered results%s", + name, action, tool, otherActionsFor(name, byAction, action)) + } + return nil +} + +// otherActionsFor names the actions that do honour the argument, so the caller +// can move the call rather than guess. +func otherActionsFor(name string, byAction map[string][]string, except string) string { + var actions []string + for action, supported := range byAction { + if action == except { + continue + } + for _, s := range supported { + if s == name { + actions = append(actions, action) + break + } + } + } + if len(actions) == 0 { + return "" + } + sort.Strings(actions) + return ". It applies to: " + strings.Join(actions, ", ") +} diff --git a/pkg/gateway/mcp/tool_actions_test.go b/pkg/gateway/mcp/tool_actions_test.go new file mode 100644 index 00000000..d531a840 --- /dev/null +++ b/pkg/gateway/mcp/tool_actions_test.go @@ -0,0 +1,241 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestRequestsToolRejectsArgsTheActionDrops is the hookdeck_requests counterpart +// of TestMetricsToolRejectsFiltersTheEndpointIgnores. +// +// The CLI has one flag set per subcommand, so `gateway request list` simply has +// no --delivery-group. This tool flattens five subcommands into one flat +// schema, so the filter was accepted, never forwarded, and the caller read 200 +// unfiltered rows as a filtered result. Every other filter narrows to zero rows +// on a bogus value, so nothing in the response revealed the drop. +func TestRequestsToolRejectsArgsTheActionDrops(t *testing.T) { + tests := []struct { + name string + action string + arg string + value any + // applies names an action that does honour the argument, so the error + // can point the caller somewhere useful. + applies string + }{ + // The reported bug: delivery_group on list. The API has no such query + // parameter on /requests, so it answered with unfiltered totals. + {"list drops delivery_group", "list", "delivery_group", "dg_bogus", "events"}, + // The same shape one action over. + {"events drops source_id", "events", "source_id", "src_bogus", "list"}, + {"events drops status", "events", "status", "SUCCESSFUL", "list"}, + {"events drops created_after", "events", "created_after", "2025-01-01T00:00:00Z", "list"}, + {"events drops verified", "events", "verified", true, "list"}, + {"ignored_events drops delivery_group", "ignored_events", "delivery_group", "dg_bogus", "events"}, + {"get drops source_id", "get", "source_id", "src_bogus", "list"}, + {"raw_body drops limit", "raw_body", "limit", float64(10), "list"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with %s, which the %s action drops", r.URL.Path, tt.arg, tt.action) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests": fail, + hookdeck.APIPathPrefix + "/requests/req_1": fail, + hookdeck.APIPathPrefix + "/requests/req_1/events": fail, + hookdeck.APIPathPrefix + "/requests/req_1/ignored_events": fail, + hookdeck.APIPathPrefix + "/requests/req_1/raw_body": fail, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": tt.action, + "id": "req_1", + tt.arg: tt.value, + }) + + assert.True(t, result.IsError, "%s with %s must be refused", tt.action, tt.arg) + body := textContent(t, result) + assert.Contains(t, body, tt.arg) + assert.Contains(t, body, tt.action) + assert.Contains(t, body, "unfiltered", "the message should say why it matters") + assert.Contains(t, body, tt.applies, "the message should name an action that honours it") + }) + } +} + +// TestEventsToolRejectsArgsTheActionDrops is the same guard on hookdeck_events: +// get and raw_body address one event by id, so a list filter passed alongside +// them was silently ignored. +func TestEventsToolRejectsArgsTheActionDrops(t *testing.T) { + tests := []struct { + name string + action string + arg string + }{ + {"get drops source_id", "get", "source_id"}, + {"get drops delivery_group", "get", "delivery_group"}, + {"raw_body drops status", "raw_body", "status"}, + {"raw_body drops connection_id", "raw_body", "connection_id"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with %s, which the %s action drops", r.URL.Path, tt.arg, tt.action) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/events": fail, + hookdeck.APIPathPrefix + "/events/evt_1": fail, + hookdeck.APIPathPrefix + "/events/evt_1/raw_body": fail, + }) + + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": tt.action, + "id": "evt_1", + tt.arg: "x_bogus", + }) + + assert.True(t, result.IsError, "%s with %s must be refused", tt.action, tt.arg) + body := textContent(t, result) + assert.Contains(t, body, tt.arg) + assert.Contains(t, body, "unfiltered") + }) + } +} + +// TestRequestsToolForwardsArgsTheActionHonours is the other half: an argument +// the action does support must still reach the API. Refusing everything would +// pass the test above and break the tool. +func TestRequestsToolForwardsArgsTheActionHonours(t *testing.T) { + t.Run("delivery_group on events", func(t *testing.T) { + var saw string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests/req_1/events": func(w http.ResponseWriter, r *http.Request) { + saw = r.URL.Query().Get("delivery_group") + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "events", "id": "req_1", "delivery_group": "dg_123", + }) + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, "dg_123", saw) + }) + + t.Run("source_id on list", func(t *testing.T) { + var saw string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { + saw = r.URL.Query().Get("source_id") + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", "source_id": "src_123", + }) + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, "src_123", saw) + }) +} + +// TestRequestsIgnoredEventsForwardsPagination covers a quieter drop of the same +// shape: the handler passed nil params, so limit/next/prev never reached a +// route that accepts all three. A caller asking for one row got the default +// page and no cursor to move off it. +func TestRequestsIgnoredEventsForwardsPagination(t *testing.T) { + var sawLimit, sawNext string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests/req_1/ignored_events": func(w http.ResponseWriter, r *http.Request) { + sawLimit = r.URL.Query().Get("limit") + sawNext = r.URL.Query().Get("next") + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "ignored_events", "id": "req_1", "limit": 5, "next": "cur_abc", + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, "5", sawLimit) + assert.Equal(t, "cur_abc", sawNext) +} + +// TestActionArgsCoverEveryDeclaredProperty stops the schema and the matrix +// drifting: a property the tool advertises that no action honours is a filter +// that can only ever be dropped or refused, and one the matrix forgets is a +// filter that silently stops working. +func TestActionArgsCoverEveryDeclaredProperty(t *testing.T) { + tests := []struct { + tool string + declared map[string]prop + byAction map[string][]string + }{ + {"hookdeck_requests", requestsToolProperties, requestsActionArgs}, + {"hookdeck_events", eventsToolProperties, eventsActionArgs}, + } + + for _, tt := range tests { + t.Run(tt.tool, func(t *testing.T) { + honoured := map[string]bool{"action": true} + for _, args := range tt.byAction { + for _, a := range args { + honoured[a] = true + _, ok := tt.declared[a] + require.True(t, ok, "%s: action matrix names %q, which the schema does not declare", tt.tool, a) + } + } + for name := range tt.declared { + assert.True(t, honoured[name], "%s: schema advertises %q but no action honours it", tt.tool, name) + } + }) + } +} + +// TestActionArgsIgnoreEmptyValues keeps the guard from firing on an argument the +// caller did not really set: the handlers forward strings and numbers only when +// non-zero, so an empty one was never a dropped filter. +func TestActionArgsIgnoreEmptyValues(t *testing.T) { + var called bool + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { + called = true + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", "delivery_group": "", "limit": 0, + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.True(t, called, "an empty argument must not block the call") +} + +// TestActionArgsIgnoreUndeclaredKeys leaves keys outside the tool's schema +// alone. They are not filters the caller expected to take effect, and MCP +// clients attach their own. +func TestActionArgsIgnoreUndeclaredKeys(t *testing.T) { + var called bool + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { + called = true + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "list", "_client_trace_id": "abc123", + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.True(t, called) +} diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index 3f880e1a..6182f886 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -21,8 +21,16 @@ func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { } action := in.String("action") + if action == "" { + action = "list" + } + // get and raw_body address one event by id: every list filter passed + // alongside them used to be dropped in silence. + if err := rejectArgsUnsupportedByAction(in, "hookdeck_events", action, eventsActionArgs, eventsToolProperties); err != nil { + return ErrorResult(err.Error()), nil + } switch action { - case "list", "": + case "list": return eventsList(ctx, client, in) case "get": return eventsGet(ctx, client, in) @@ -97,4 +105,3 @@ func eventsRawBody(ctx context.Context, client *hookdeck.Client, in input) (*mcp } return JSONResultEnvelopeForClient(map[string]string{"raw_body": text}, client) } - diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index 192f3701..171040cd 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -311,14 +311,38 @@ Parameters: start (string, required) — ISO 8601 datetime end (string, required) — ISO 8601 datetime granularity (string) — e.g. "1h", "5m", "1d" - measures (string[], required) — Metrics to retrieve. Common: count, successful_count, failed_count, error_count - dimensions (string[]) — Grouping dimensions (varies by action) - source_id (string) — Filter by source - destination_id (string) — Filter by destination - delivery_group (string) — Filter by delivery group (events and attempts) - connection_id (string) — Filter by connection (maps to webhook_id) - status (string) — Filter by status - issue_id (string) — Filter by issue (events only)`, + measures (string[], required) — Metrics to retrieve (see Measures below) + dimensions (string[]) — Grouping dimensions (see Dimensions below) + source_id (string) — Filter by source (events, requests) + destination_id (string) — Filter by destination (events, attempts) + delivery_group (string) — Filter by delivery group (events, attempts) + connection_id (string) — Filter by connection, maps to webhook_id (events, transformations) + status (string) — Filter by status (events, requests, attempts) + issue_id (string) — Filter by issue (transformations; events when grouping by issue_id) + +Measures per action (only count is valid on all four): + events — ` + hookdeck.EventMetricsMeasures + ` + requests — ` + hookdeck.RequestMetricsMeasures + ` + attempts — ` + hookdeck.AttemptMetricsMeasures + ` + transformations — ` + hookdeck.TransformationMetricsMeasures + ` + +Dimensions per action: + events — ` + hookdeck.EventMetricsDimensions + ` + requests — ` + hookdeck.RequestMetricsDimensions + ` + attempts — ` + hookdeck.AttemptMetricsDimensions + ` + transformations — ` + hookdeck.TransformationMetricsDimensions + ` + + On events the accepted set narrows with the route the measures select: + queue_depth / max_depth / max_age — ` + hookdeck.DimensionList(hookdeck.QueueDepthRouteDimensions) + ` + pending — ` + hookdeck.DimensionList(hookdeck.PendingTimeseriesRouteDimensions) + ` + issue_id (per-issue) — ` + hookdeck.DimensionList(hookdeck.EventsByIssueRouteDimensions) + ` + Grouping by delivery_group also requires destination_id; the API rejects it otherwise. + +Status values per action: + events — ` + hookdeck.EventStatusValues + ` + requests — ` + hookdeck.RequestStatusValues + ` + attempts — ` + hookdeck.AttemptStatusValues + ` + transformations — not supported`, "hookdeck_help": `hookdeck_help — Get an overview of available tools or detailed help for a specific tool diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index 045477e4..d837097d 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -41,6 +41,14 @@ func rejectFilters(params hookdeck.MetricsQueryParams, allowed hookdeck.MetricsF return hookdeck.RejectUnsupportedFilters(params, allowed, route, hookdeck.MCPFilterNames) } +// rejectDimensions is the dimension counterpart. Filters were gated per route +// and dimensions were not, so a dimension the route does not define reached the +// API as a raw 422 - including the delivery_group grouping this release is +// about. Both layers read the same matrix so they cannot drift. +func rejectDimensions(params hookdeck.MetricsQueryParams, allowed []string, route string) error { + return hookdeck.RejectUnsupportedDimensions(params, allowed, route, hookdeck.MCPFilterNames, "dimensions") +} + // mapDimensions rewrites connection_id to the webhook_id the API expects. The // tool schema tells callers connection_id "maps to webhook_id", which was true // of the filter and not of the dimension. @@ -102,9 +110,13 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp return ErrorResult(err.Error()), nil } - // Only one endpoint is called, so measures belonging to different ones - // cannot all be answered. Shared with the CLI so the two cannot drift. - if err := hookdeck.RejectMixedMeasureRoutes(params.Measures, "measures"); err != nil { + // Only one endpoint is called, so parts of the query naming different ones + // cannot all be answered. The routing below is ordered - measures, then the + // issue_id dimension, then the issue filter - and first match wins, so a + // queue-depth measure silently shadowed a per-issue question (#407) rather + // than answering it. Shared with the CLI so the two cannot drift; this call + // subsumes RejectMixedMeasureRoutes and must not be paired with it. + if err := hookdeck.RejectCrossRouteEventQuery(params, "measures", "dimensions", hookdeck.MCPFilterNames); err != nil { return ErrorResult(err.Error()), nil } @@ -117,6 +129,9 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp if err := rejectFilters(params, hookdeck.QueueDepthRouteFilters, "queue depth metrics"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.QueueDepthRouteDimensions, "queue depth metrics"); err != nil { + return ErrorResult(err.Error()), nil + } // The endpoint accepts max_depth and max_age only; "queue_depth" is our // own spelling for the route, so translate it as the CLI does. queueParams := params @@ -126,6 +141,9 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp if err := rejectFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (measures: pending)"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, "pending event metrics (measures: pending)"); err != nil { + return ErrorResult(err.Error()), nil + } // The API expects measures[]=count here; "pending" only selects the // route. Without this the request carries a measure the endpoint does // not define - the CLI has always rewritten it, MCP did not. @@ -139,11 +157,17 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp if err := rejectFilters(params, hookdeck.EventsByIssueRouteFilters, "per-issue event metrics"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.EventsByIssueRouteDimensions, "per-issue event metrics"); err != nil { + return ErrorResult(err.Error()), nil + } result, err = client.QueryEventsByIssue(ctx, params) default: if err := rejectFilters(params, hookdeck.DefaultEventRouteFilters, "event metrics"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.DefaultEventRouteDimensions, "event metrics"); err != nil { + return ErrorResult(err.Error()), nil + } result, err = client.QueryEventMetrics(ctx, params) } @@ -161,6 +185,9 @@ func metricsRequests(ctx context.Context, client *hookdeck.Client, in input) (*m if err := rejectFilters(params, hookdeck.RequestMetricsFilters, "request metrics"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.RequestMetricsDimensionValues, "request metrics"); err != nil { + return ErrorResult(err.Error()), nil + } result, err := client.QueryRequestMetrics(ctx, params) if err != nil { return ErrorResult(TranslateAPIError(err)), nil @@ -176,6 +203,9 @@ func metricsAttempts(ctx context.Context, client *hookdeck.Client, in input) (*m if err := rejectFilters(params, hookdeck.AttemptMetricsFilters, "attempt metrics"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.AttemptMetricsDimensionValues, "attempt metrics"); err != nil { + return ErrorResult(err.Error()), nil + } result, err := client.QueryAttemptMetrics(ctx, params) if err != nil { return ErrorResult(TranslateAPIError(err)), nil @@ -191,6 +221,9 @@ func metricsTransformations(ctx context.Context, client *hookdeck.Client, in inp if err := rejectFilters(params, hookdeck.TransformationMetricsFilters, "transformation metrics"); err != nil { return ErrorResult(err.Error()), nil } + if err := rejectDimensions(params, hookdeck.TransformationMetricsDimensionValues, "transformation metrics"); err != nil { + return ErrorResult(err.Error()), nil + } result, err := client.QueryTransformationMetrics(ctx, params) if err != nil { return ErrorResult(TranslateAPIError(err)), nil diff --git a/pkg/gateway/mcp/tool_metrics_dimensions_test.go b/pkg/gateway/mcp/tool_metrics_dimensions_test.go new file mode 100644 index 00000000..c7ea912e --- /dev/null +++ b/pkg/gateway/mcp/tool_metrics_dimensions_test.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestMetricsToolRejectsDimensionsTheRouteIgnores is the dimension counterpart +// of TestMetricsToolRejectsFiltersTheEndpointIgnores. +// +// Filters were gated per route and dimensions were not gated at all, so a +// dimension the route does not define reached the API as a raw 422 for +// something the flat schema appeared to offer. Both layers read the same matrix +// in pkg/hookdeck so they cannot drift. +func TestMetricsToolRejectsDimensionsTheRouteIgnores(t *testing.T) { + tests := []struct { + name string + action string + measures []any + dimension string + contains []string + }{ + // 422 dimensions[0] must be [destination_id] + { + name: "pending timeseries groups by destination only", action: "events", + measures: []any{"pending"}, dimension: "status", + contains: []string{"status", "pending event metrics", "destination_id"}, + }, + { + name: "queue depth has no status dimension", action: "events", + measures: []any{"queue_depth"}, dimension: "status", + contains: []string{"queue depth metrics", "destination_id, delivery_group"}, + }, + { + name: "default event route has no issue_id dimension", action: "events", + measures: []any{"count"}, dimension: "rejection_cause", + contains: []string{"rejection_cause", "event metrics"}, + }, + { + name: "requests do not group by destination", action: "requests", + measures: []any{"count"}, dimension: "destination_id", + contains: []string{"destination_id", "request metrics"}, + }, + { + name: "attempts do not group by source", action: "attempts", + measures: []any{"count"}, dimension: "source_id", + contains: []string{"source_id", "attempt metrics"}, + }, + { + name: "transformations do not group by status", action: "transformations", + measures: []any{"count"}, dimension: "status", + contains: []string{"status", "transformation metrics"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with a dimension it does not define (%s)", r.URL.Path, tt.dimension) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": fail, + hookdeck.APIPathPrefix + "/metrics/events-by-issue": fail, + hookdeck.APIPathPrefix + "/metrics/events-pending-timeseries": fail, + hookdeck.APIPathPrefix + "/metrics/queue-depth": fail, + hookdeck.APIPathPrefix + "/metrics/requests": fail, + hookdeck.APIPathPrefix + "/metrics/attempts": fail, + hookdeck.APIPathPrefix + "/metrics/transformations": fail, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": tt.action, + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": tt.measures, + "dimensions": []any{tt.dimension}, + }) + + assert.True(t, result.IsError, "%s grouped by %s must be refused", tt.action, tt.dimension) + body := textContent(t, result) + for _, want := range tt.contains { + assert.Contains(t, body, want) + } + }) + } +} + +// TestMetricsToolRejectsDeliveryGroupDimensionWithoutDestination covers the +// API's one cross-field rule, and the worst of these to leave unguarded: this +// is the delivery-group grouping the release exists for, appearing usable and +// answering "422 The delivery_group dimension requires a filters.destination_id +// filter". The message has to name the fix, because adding destination_id works. +func TestMetricsToolRejectsDeliveryGroupDimensionWithoutDestination(t *testing.T) { + for _, action := range []string{"events", "attempts"} { + t.Run(action, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s: the API rejects delivery_group grouping without destination_id", r.URL.Path) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": fail, + hookdeck.APIPathPrefix + "/metrics/attempts": fail, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": action, + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "dimensions": []any{"delivery_group"}, + }) + + assert.True(t, result.IsError) + body := textContent(t, result) + assert.Contains(t, body, "delivery_group") + assert.Contains(t, body, "destination_id", "the message must name the filter that fixes it") + }) + } +} + +// TestMetricsToolAcceptsDimensionsTheRouteHonours is the other half. Grouping +// by delivery_group with a destination filter is the combination that works +// against the live API, so it must still reach it. +func TestMetricsToolAcceptsDimensionsTheRouteHonours(t *testing.T) { + var sawDimensions []string + var sawDestination string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": func(w http.ResponseWriter, r *http.Request) { + sawDimensions = r.URL.Query()["dimensions[]"] + sawDestination = r.URL.Query().Get("filters[destination_id]") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "dimensions": []any{"delivery_group"}, + "destination_id": "des_123", + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, []string{"delivery_group"}, sawDimensions) + assert.Equal(t, "des_123", sawDestination) +} + +// TestMetricsToolMapsConnectionDimensionBeforeGating makes sure the caller's +// connection_id spelling is translated before it is checked, so the alias the +// schema documents is not refused by the new gate. +func TestMetricsToolMapsConnectionDimensionBeforeGating(t *testing.T) { + var sawDimensions []string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": func(w http.ResponseWriter, r *http.Request) { + sawDimensions = r.URL.Query()["dimensions[]"] + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "dimensions": []any{"connection_id"}, + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, []string{"webhook_id"}, sawDimensions) +} diff --git a/pkg/gateway/mcp/tool_metrics_measures_test.go b/pkg/gateway/mcp/tool_metrics_measures_test.go index 7d07636a..2c59e3b8 100644 --- a/pkg/gateway/mcp/tool_metrics_measures_test.go +++ b/pkg/gateway/mcp/tool_metrics_measures_test.go @@ -81,3 +81,102 @@ func TestMetricsToolAcceptsSingleRouteMeasures(t *testing.T) { assert.False(t, result.IsError) assert.Equal(t, []string{"count", "failed_count"}, sawMeasures) } + +// TestMetricsToolRejectsCrossRouteEventQuery covers #407 on the MCP surface. +// +// `events` routing is ordered - measures, then the issue_id dimension, then the +// issue filter - and first match wins. A queue-depth or pending measure +// therefore shadowed a per-issue question entirely: the call returned queue +// depth, reported success, and never mentioned that the issue_id half of the +// question had been dropped. The CLI fix landed in shared code (#407); this is +// the same guard on the tool, which has the identical ordered routing. +func TestMetricsToolRejectsCrossRouteEventQuery(t *testing.T) { + tests := []struct { + name string + args map[string]any + contains []string + }{ + { + name: "queue depth measure shadows the issue_id dimension", + args: map[string]any{ + "measures": []any{"queue_depth"}, + "dimensions": []any{"issue_id"}, + "issue_id": "iss_1", + }, + contains: []string{`"queue_depth"`, "queue depth metrics", "per-issue event metrics", "dimensions"}, + }, + { + name: "pending measure shadows the issue_id dimension", + args: map[string]any{ + "measures": []any{"pending"}, + "dimensions": []any{"issue_id"}, + "issue_id": "iss_1", + }, + contains: []string{`"pending"`, "pending event metrics", "per-issue event metrics"}, + }, + { + name: "queue depth measure shadows the issue filter on its own", + args: map[string]any{ + "measures": []any{"max_depth"}, + "issue_id": "iss_1", + }, + contains: []string{`"max_depth"`, "queue depth metrics", "issue_id", "per-issue event metrics"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s: the query names two routes", r.URL.Path) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": fail, + hookdeck.APIPathPrefix + "/metrics/events-by-issue": fail, + hookdeck.APIPathPrefix + "/metrics/events-pending-timeseries": fail, + hookdeck.APIPathPrefix + "/metrics/queue-depth": fail, + }) + + args := map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + } + for k, v := range tt.args { + args[k] = v + } + + result := callTool(t, session, "hookdeck_metrics", args) + + assert.True(t, result.IsError, "a query naming two routes must be refused") + body := textContent(t, result) + for _, want := range tt.contains { + assert.Contains(t, body, want, "the error must name both routes") + } + }) + } +} + +// TestMetricsToolStillAnswersSingleRouteEventQueries is the other half: a +// per-issue question with a default-route measure is a per-issue count, not a +// conflict, and must still reach the by-issue endpoint. +func TestMetricsToolStillAnswersSingleRouteEventQueries(t *testing.T) { + var called bool + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events-by-issue": func(w http.ResponseWriter, r *http.Request) { + called = true + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "dimensions": []any{"issue_id"}, + "issue_id": "iss_1", + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.True(t, called, "a per-issue count must still reach the by-issue endpoint") +} diff --git a/pkg/gateway/mcp/tool_metrics_schema_test.go b/pkg/gateway/mcp/tool_metrics_schema_test.go new file mode 100644 index 00000000..5caa5dcb --- /dev/null +++ b/pkg/gateway/mcp/tool_metrics_schema_test.go @@ -0,0 +1,160 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// metricsSchemaProperty returns one property of the live hookdeck_metrics tool +// schema, as an MCP client would read it off tools/list. +func metricsSchemaProperty(t *testing.T, name string) map[string]any { + t.Helper() + client := newTestClient("https://api.hookdeck.com", "test-api-key") + session := connectInMemory(t, client) + + listed, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + + for _, tool := range listed.Tools { + if tool.Name != "hookdeck_metrics" { + continue + } + raw, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema struct { + Properties map[string]map[string]any `json:"properties"` + } + require.NoError(t, json.Unmarshal(raw, &schema)) + prop, ok := schema.Properties[name] + require.True(t, ok, "hookdeck_metrics schema has no %q property", name) + return prop + } + t.Fatal("hookdeck_metrics not listed") + return nil +} + +// TestMetricsSchemaMeasuresAreAccuratePerAction pins the measures contract. +// +// There is no enum and no per-action breakdown on this property, so its +// description IS what a client plans against. It used to read "Common: count, +// successful_count, failed_count, error_count" - one list standing in for four +// endpoints, of which only count works on all four: error_count is valid on +// transformations alone, and successful_count and failed_count both 422 on +// requests. The CLI documents the right list per subcommand; this puts the same +// four lists, from the same constants, into the tool schema. +func TestMetricsSchemaMeasuresAreAccuratePerAction(t *testing.T) { + desc, _ := metricsSchemaProperty(t, "measures")["description"].(string) + require.NotEmpty(t, desc) + + for action, measures := range map[string]string{ + "events": hookdeck.EventMetricsMeasures, + "requests": hookdeck.RequestMetricsMeasures, + "attempts": hookdeck.AttemptMetricsMeasures, + "transformations": hookdeck.TransformationMetricsMeasures, + } { + assert.Contains(t, desc, action+": "+measures, + "measures description must carry the real %s list", action) + } + + // The specific claims that were wrong. error_count is a transformations + // measure only, and the requests endpoint has neither success nor failure + // counts - it counts accepted and rejected. + assert.NotContains(t, desc, "Common: count, successful_count, failed_count, error_count", + "the inaccurate blanket list must be gone") + requestsPart := actionSlice(t, desc, "requests: ", "; attempts:") + for _, absent := range []string{"successful_count", "failed_count", "error_count"} { + assert.NotContains(t, requestsPart, absent, + "requests metrics do not accept %s", absent) + } + assert.Contains(t, actionSlice(t, desc, "transformations: ", ". Only count"), "error_count", + "error_count is valid on transformations") +} + +// TestMetricsSchemaDimensionsAreAccuratePerAction is the same guard for +// dimensions, plus the cross-field rule a client cannot discover any other way. +func TestMetricsSchemaDimensionsAreAccuratePerAction(t *testing.T) { + desc, _ := metricsSchemaProperty(t, "dimensions")["description"].(string) + require.NotEmpty(t, desc) + + for action, dimensions := range map[string]string{ + "events": hookdeck.EventMetricsDimensions, + "requests": hookdeck.RequestMetricsDimensions, + "attempts": hookdeck.AttemptMetricsDimensions, + "transformations": hookdeck.TransformationMetricsDimensions, + } { + assert.Contains(t, desc, action+": "+dimensions, + "dimensions description must carry the real %s list", action) + } + + assert.Contains(t, desc, "delivery_group also requires destination_id", + "the API's cross-field rule must be documented, not discovered as a 422") + assert.NotEqual(t, "Grouping dimensions", desc, "the placeholder description must be gone") +} + +// TestMetricsSchemaStatusIsAccuratePerAction pins the third parameter that +// decides whether a call succeeds. status means something different on each +// action - accepted/rejected at the edge, a delivery status on events and +// attempts, nothing at all on transformations - and the schema said only +// "Filter by status". +func TestMetricsSchemaStatusIsAccuratePerAction(t *testing.T) { + desc, _ := metricsSchemaProperty(t, "status")["description"].(string) + require.NotEmpty(t, desc) + + assert.Contains(t, desc, "events: "+hookdeck.EventStatusValues) + assert.Contains(t, desc, "requests: "+hookdeck.RequestStatusValues) + assert.Contains(t, desc, "attempts: "+hookdeck.AttemptStatusValues) + assert.Contains(t, desc, "Not supported on transformations") +} + +// actionSlice cuts the part of a description belonging to one action, so a +// value can be asserted absent from that action without tripping over another +// action that legitimately has it. +func actionSlice(t *testing.T, desc, from, to string) string { + t.Helper() + start := strings.Index(desc, from) + require.GreaterOrEqual(t, start, 0, "description has no %q section", from) + rest := desc[start+len(from):] + end := strings.Index(rest, to) + require.GreaterOrEqual(t, end, 0, "description section %q is not terminated by %q", from, to) + return rest[:end] +} + +// TestAPIValidationErrorReachesTheClientReadable pins the wiring, not just the +// helper: a 422 has to arrive at the MCP client as the one line worth reading. +// +// These bodies carry no top-level "message", so the whole thing was pasted into +// the error text - {"level":"info","handled":true,"report":true,...} and all - +// with the useful part buried mid-string. Every gated error in this tool that +// the client side does not catch first ends up on this path. +func TestAPIValidationErrorReachesTheClientReadable(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"level":"info","handled":true,"report":true,"data":["granularity must match the required pattern"],"status":422,"code":"UNPROCESSABLE_ENTITY"}`)) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "granularity": "not-a-granularity", + }) + + require.True(t, result.IsError) + body := textContent(t, result) + assert.Equal(t, "granularity must match the required pattern", body, + "the client should get the message and nothing else") + for _, leaked := range []string{"level", "handled", "report", "UNPROCESSABLE_ENTITY", "status"} { + assert.NotContains(t, body, leaked, "internal field %q must not reach the client", leaked) + } +} diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 8d01e35d..66184cb2 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -23,8 +23,17 @@ func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { } action := in.String("action") + if action == "" { + action = "list" + } + // The CLI has one flag set per subcommand; this tool flattens five of + // them into one schema, so a filter meant for a sibling action would be + // accepted and then dropped without ever reaching the API. + if err := rejectArgsUnsupportedByAction(in, "hookdeck_requests", action, requestsActionArgs, requestsToolProperties); err != nil { + return ErrorResult(err.Error()), nil + } switch action { - case "list", "": + case "list": return requestsList(ctx, client, in) case "get": return requestsGet(ctx, client, in) @@ -124,10 +133,16 @@ func requestsIgnoredEvents(ctx context.Context, client *hookdeck.Client, in inpu if id == "" { return ErrorResult("id is required for the ignored_events action"), nil } - result, err := client.GetRequestIgnoredEvents(ctx, id, nil) + // The route takes limit/next/prev (and the CLI passes them); sending nil + // dropped all three, so a caller asking for 5 rows silently got the default + // page and had no cursor to move off it. + params := make(map[string]string) + setInt(params, "limit", in.Int("limit", 0)) + setIfNonEmpty(params, "next", in.String("next")) + setIfNonEmpty(params, "prev", in.String("prev")) + result, err := client.GetRequestIgnoredEvents(ctx, id, params) if err != nil { return ErrorResult(TranslateAPIError(err)), nil } return JSONResultEnvelopeForClient(result, client) } - diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index 1deb4bc1..d0f69a7b 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -97,28 +97,7 @@ func toolDefs(client *hookdeck.Client) []struct { tool: &mcpsdk.Tool{ Name: "hookdeck_requests", Description: "Query inbound requests (raw HTTP data received by Hookdeck before routing). List supports the same filters as `hookdeck gateway request list` (metadata, date range, payload search, sort). Get details, inspect raw body, or view events and ignored events from a request. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", - InputSchema: schema(map[string]prop{ - "action": {Type: "string", Desc: "Action: list, get, raw_body, events, or ignored_events", Enum: []string{"list", "get", "raw_body", "events", "ignored_events"}}, - "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events"}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, - "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, - "delivery_group": {Type: "string", Desc: "Filter by delivery group (events action)"}, - "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, - "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, - "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, - "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, - "ingested_before": {Type: "string", Desc: "ingested_at upper bound. " + descDateBefore}, - "body": {Type: "string", Desc: "Filter by request body. " + descJSONFilter}, - "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, - "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, - "path": {Type: "string", Desc: descPathFilter}, - "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), + InputSchema: schema(requestsToolProperties, "action"), }, handler: handleRequests(client), }, @@ -126,35 +105,7 @@ func toolDefs(client *hookdeck.Client) []struct { tool: &mcpsdk.Tool{ Name: "hookdeck_events", Description: "Query events (processed deliveries routed through connections to destinations). List supports the same filters as `hookdeck gateway event list` (metadata, date range, payload search, sort). Get event details (get) or the event payload (raw_body). Use action raw_body with the event id to get the payload directly — do not use hookdeck_requests for the payload when you already have an event id. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", - InputSchema: schema(map[string]prop{ - "action": {Type: "string", Desc: "Action: list, get, or raw_body. Use raw_body to get the event payload (body); get returns metadata and headers only.", Enum: []string{"list", "get", "raw_body"}}, - "id": {Type: "string", Desc: "Event ID: filter by ID(s) on list (comma-separated), or required for get/raw_body"}, - "connection_id": {Type: "string", Desc: "Filter by connection (list, maps to webhook_id)"}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "destination_id": {Type: "string", Desc: "Filter by destination (list)"}, - "delivery_group": {Type: "string", Desc: "Filter by delivery group (list)"}, - "status": {Type: "string", Desc: "Event status: SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED"}, - "attempts": {Type: "string", Desc: "Filter by attempt count (list). Integer or API operator syntax; pass through as string."}, - "issue_id": {Type: "string", Desc: "Filter by issue (list)"}, - "error_code": {Type: "string", Desc: "Filter by error code (list)"}, - "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, - "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (list)"}, - "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, - "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, - "successful_after": {Type: "string", Desc: "successful_at lower bound. " + descDateAfter}, - "successful_before": {Type: "string", Desc: "successful_at upper bound. " + descDateBefore}, - "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound. " + descDateAfter}, - "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound. " + descDateBefore}, - "body": {Type: "string", Desc: "Filter by event payload body. " + descJSONFilter}, - "headers": {Type: "string", Desc: "Filter by event headers. " + descJSONFilter}, - "parsed_query": {Type: "string", Desc: "Filter by parsed query as JSON. " + descJSONFilter}, - "path": {Type: "string", Desc: descPathFilter}, - "limit": {Type: "integer", Desc: "Max results (list)"}, - "order_by": {Type: "string", Desc: "Sort field (list)"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "next": {Type: "string", Desc: "Next page cursor"}, - "prev": {Type: "string", Desc: "Previous page cursor"}, - }, "action"), + InputSchema: schema(eventsToolProperties, "action"), }, handler: handleEvents(client), }, @@ -203,13 +154,13 @@ func toolDefs(client *hookdeck.Client) []struct { "start": {Type: "string", Desc: "Start datetime (ISO 8601, required)"}, "end": {Type: "string", Desc: "End datetime (ISO 8601, required)"}, "granularity": {Type: "string", Desc: "Time bucket size, e.g. 1h, 5m, 1d"}, - "measures": {Type: "array", Desc: "Metrics to retrieve (required). Common: count, successful_count, failed_count, error_count", Items: &prop{Type: "string"}}, - "dimensions": {Type: "array", Desc: "Grouping dimensions", Items: &prop{Type: "string"}}, + "measures": {Type: "array", Desc: descMetricsMeasures, Items: &prop{Type: "string"}}, + "dimensions": {Type: "array", Desc: descMetricsDimensions, Items: &prop{Type: "string"}}, "source_id": {Type: "string", Desc: "Filter by source (events, requests)"}, "destination_id": {Type: "string", Desc: "Filter by destination (events, attempts)"}, "delivery_group": {Type: "string", Desc: "Filter by delivery group (events, attempts)"}, "connection_id": {Type: "string", Desc: "Filter by connection, maps to webhook_id (events, transformations)"}, - "status": {Type: "string", Desc: "Filter by status (events, requests, attempts)"}, + "status": {Type: "string", Desc: descMetricsStatus}, "issue_id": {Type: "string", Desc: "Filter by issue (transformations; events when grouping by issue_id)"}, }, "action", "start", "end", "measures"), }, @@ -228,6 +179,64 @@ func toolDefs(client *hookdeck.Client) []struct { } } +// requestsToolProperties is the hookdeck_requests schema. It is a package var +// so requestsActionArgs can be checked against it: an argument the schema +// advertises but no action honours is a filter that would vanish in silence. +var requestsToolProperties = map[string]prop{ + "action": {Type: "string", Desc: "Action: list, get, raw_body, events, or ignored_events", Enum: []string{"list", "get", "raw_body", "events", "ignored_events"}}, + "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events"}, + "source_id": {Type: "string", Desc: "Filter by source (list)"}, + "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, + "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, + "delivery_group": {Type: "string", Desc: "Filter by delivery group (events action only; the /requests collection has no such filter, so list rejects it rather than return unfiltered rows)"}, + "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, + "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, + "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, + "ingested_before": {Type: "string", Desc: "ingested_at upper bound. " + descDateBefore}, + "body": {Type: "string", Desc: "Filter by request body. " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter}, + "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "limit": {Type: "integer", Desc: "Max results (list, events, ignored_events)"}, + "next": {Type: "string", Desc: "Next page cursor (list, events, ignored_events)"}, + "prev": {Type: "string", Desc: "Previous page cursor (list, events, ignored_events)"}, +} + +// eventsToolProperties is the hookdeck_events schema, held as a var for the +// same reason as requestsToolProperties. +var eventsToolProperties = map[string]prop{ + "action": {Type: "string", Desc: "Action: list, get, or raw_body. Use raw_body to get the event payload (body); get returns metadata and headers only.", Enum: []string{"list", "get", "raw_body"}}, + "id": {Type: "string", Desc: "Event ID: filter by ID(s) on list (comma-separated), or required for get/raw_body"}, + "connection_id": {Type: "string", Desc: "Filter by connection (list, maps to webhook_id)"}, + "source_id": {Type: "string", Desc: "Filter by source (list)"}, + "destination_id": {Type: "string", Desc: "Filter by destination (list)"}, + "delivery_group": {Type: "string", Desc: "Filter by delivery group (list)"}, + "status": {Type: "string", Desc: "Event status (list): " + hookdeck.EventStatusValues}, + "attempts": {Type: "string", Desc: "Filter by attempt count (list). Integer or API operator syntax; pass through as string."}, + "issue_id": {Type: "string", Desc: "Filter by issue (list)"}, + "error_code": {Type: "string", Desc: "Filter by error code (list)"}, + "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, + "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (list)"}, + "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, + "successful_after": {Type: "string", Desc: "successful_at lower bound. " + descDateAfter}, + "successful_before": {Type: "string", Desc: "successful_at upper bound. " + descDateBefore}, + "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound. " + descDateAfter}, + "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound. " + descDateBefore}, + "body": {Type: "string", Desc: "Filter by event payload body. " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by event headers. " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query as JSON. " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter}, + "limit": {Type: "integer", Desc: "Max results (list)"}, + "order_by": {Type: "string", Desc: "Sort field (list)"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, + "next": {Type: "string", Desc: "Next page cursor (list)"}, + "prev": {Type: "string", Desc: "Previous page cursor (list)"}, +} + // prop describes a single JSON Schema property. type prop struct { Type string `json:"type"` @@ -243,6 +252,38 @@ const ( descPathFilter = "Partial URL path match (string)." ) +// measures, dimensions and status decide whether a metrics call succeeds, and +// each of the four actions has its own vocabulary. One flat schema cannot carry +// four enums, so the per-action lists go in the description - built from the +// same constants the CLI's --help reads, because a hand-written "Common: +// count, successful_count, failed_count, error_count" was the contract clients +// planned against and three quarters of it 422s on most actions. +var ( + descMetricsMeasures = "Metrics to retrieve (required). Valid values differ per action — " + + "events: " + hookdeck.EventMetricsMeasures + "; " + + "requests: " + hookdeck.RequestMetricsMeasures + "; " + + "attempts: " + hookdeck.AttemptMetricsMeasures + "; " + + "transformations: " + hookdeck.TransformationMetricsMeasures + ". " + + "Only count is valid on all four." + + descMetricsDimensions = "Grouping dimensions. Valid values differ per action — " + + "events: " + hookdeck.EventMetricsDimensions + "; " + + "requests: " + hookdeck.RequestMetricsDimensions + "; " + + "attempts: " + hookdeck.AttemptMetricsDimensions + "; " + + "transformations: " + hookdeck.TransformationMetricsDimensions + ". " + + "On events the accepted set narrows with the route the measures select " + + "(queue_depth: " + hookdeck.DimensionList(hookdeck.QueueDepthRouteDimensions) + "; " + + "pending: " + hookdeck.DimensionList(hookdeck.PendingTimeseriesRouteDimensions) + "; " + + "issue_id: " + hookdeck.DimensionList(hookdeck.EventsByIssueRouteDimensions) + "). " + + "Grouping by delivery_group also requires destination_id." + + descMetricsStatus = "Filter by status. Values differ per action — " + + "events: " + hookdeck.EventStatusValues + "; " + + "requests: " + hookdeck.RequestStatusValues + "; " + + "attempts: " + hookdeck.AttemptStatusValues + ". " + + "Not supported on transformations." +) + // schema builds a JSON Schema object with the given properties and required fields. func schema(properties map[string]prop, required ...string) json.RawMessage { s := map[string]interface{}{ diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 178a727b..48d60b5f 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -310,6 +310,46 @@ func (c *Client) Put(ctx context.Context, path string, data []byte, configure fu return c.PerformRequest(ctx, req) } +// apiErrorMessage extracts the human-readable part of a Hookdeck error body. +// +// Validation failures (422) carry no top-level "message": the one useful line +// sits in data[], so the whole body was being pasted into the error text - +// internal fields and all ({"level":"info","handled":true,...}) - with the +// message the caller needs buried in the middle of it. +// +// data[] entries are seen both as plain strings and as objects with a message +// field, so both are read. Returns "" when nothing readable is found, leaving +// the caller to fall back to the raw body. +func apiErrorMessage(body []byte) string { + var payload struct { + Message string `json:"message"` + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + if payload.Message != "" { + return payload.Message + } + messages := make([]string, 0, len(payload.Data)) + for _, item := range payload.Data { + var text string + if err := json.Unmarshal(item, &text); err == nil { + if text != "" { + messages = append(messages, text) + } + continue + } + var obj struct { + Message string `json:"message"` + } + if err := json.Unmarshal(item, &obj); err == nil && obj.Message != "" { + messages = append(messages, obj.Message) + } + } + return strings.Join(messages, "; ") +} + func checkAndPrintError(res *http.Response) error { if res.StatusCode != http.StatusOK { if res.Body != nil { @@ -328,10 +368,10 @@ func checkAndPrintError(res *http.Response) error { Message: fmt.Sprintf("unexpected http status code: %d, raw response body: %s", res.StatusCode, body), } } - if response.Message != "" { + if msg := apiErrorMessage(body); msg != "" { return &APIError{ StatusCode: res.StatusCode, - Message: response.Message, + Message: msg, } } return &APIError{ diff --git a/pkg/hookdeck/client_error_message_test.go b/pkg/hookdeck/client_error_message_test.go new file mode 100644 index 00000000..9178e3aa --- /dev/null +++ b/pkg/hookdeck/client_error_message_test.go @@ -0,0 +1,76 @@ +package hookdeck + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestAPIErrorMessageSurfacesTheUsefulLine covers the 422 bodies that were +// reaching callers whole. +// +// A validation failure carries no top-level "message": the one line worth +// reading sits in data[], so the entire body - internal fields included +// ({"level":"info","handled":true,"report":true,...}) - was pasted into the +// error text with the useful part buried in the middle of it. Every gated error +// in the metrics and requests tools eventually lands here. +func TestAPIErrorMessageSurfacesTheUsefulLine(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "422 validation body puts the message in data[]", + body: `{"level":"info","handled":true,"report":true,"data":["The delivery_group dimension requires a filters.destination_id filter"],"status":422,"code":"UNPROCESSABLE_ENTITY"}`, + want: "The delivery_group dimension requires a filters.destination_id filter", + }, + { + name: "measure enum rejection", + body: `{"level":"info","handled":true,"data":["measures[0] must be one of [count, accepted_count]"],"status":422}`, + want: "measures[0] must be one of [count, accepted_count]", + }, + { + name: "several data entries are joined", + body: `{"data":["first problem","second problem"],"status":422}`, + want: "first problem; second problem", + }, + { + name: "data entries may be objects", + body: `{"data":[{"message":"nested problem"}],"status":422}`, + want: "nested problem", + }, + { + name: "a top-level message still wins", + body: `{"message":"Not found","data":["ignored"],"status":404}`, + want: "Not found", + }, + { + name: "nothing readable falls back to the caller", + body: `{"status":500}`, + want: "", + }, + { + name: "a non-JSON body falls back to the caller", + body: `bad gateway`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, apiErrorMessage([]byte(tt.body))) + }) + } +} + +// TestAPIErrorMessageDropsInternalFields is the point of the change, stated +// directly: whatever else happens, the transport's own bookkeeping must not +// reach an MCP client as error text. +func TestAPIErrorMessageDropsInternalFields(t *testing.T) { + got := apiErrorMessage([]byte(`{"level":"info","handled":true,"report":true,"data":["dimensions[0] must be [destination_id]"],"status":422,"code":"UNPROCESSABLE_ENTITY"}`)) + for _, leaked := range []string{"level", "handled", "report", "UNPROCESSABLE_ENTITY"} { + assert.NotContains(t, got, leaked) + } + assert.Equal(t, "dimensions[0] must be [destination_id]", got) +} diff --git a/pkg/hookdeck/metrics_dimensions_test.go b/pkg/hookdeck/metrics_dimensions_test.go new file mode 100644 index 00000000..531c7a6f --- /dev/null +++ b/pkg/hookdeck/metrics_dimensions_test.go @@ -0,0 +1,176 @@ +package hookdeck + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMetricsDimensionsMatchTheEndpointSchemas is the dimension counterpart of +// TestMetricsFlagsMatchTheEndpointSchemas in pkg/cmd. +// +// Expectations are hardcoded, so an API-side change will NOT fail this test; it +// pins the matrix both the CLI and MCP read, so the two cannot drift. Re-check +// against the `dimensions` enum of each GET /metrics/* operation in +// https://api.hookdeck.com/2026-09-01/openapi when the version moves. +func TestMetricsDimensionsMatchTheEndpointSchemas(t *testing.T) { + tests := []struct { + name string + actual []string + want []string + }{ + {"requests", RequestMetricsDimensionValues, + []string{"source_id", "rejection_cause", "status", "bulk_retry_ids", "events_count", "ignored_count"}}, + {"attempts", AttemptMetricsDimensionValues, + []string{"destination_id", "delivery_group", "event_id", "status", "error_code", "bulk_retry_id", "trigger"}}, + {"transformations", TransformationMetricsDimensionValues, + []string{"transformation_id", "webhook_id", "log_level", "issue_id"}}, + {"events (default route)", DefaultEventRouteDimensions, + []string{"source_id", "destination_id", "webhook_id", "delivery_group", "status", "error_code", "event_data_id", "cli_id", "cli_user_id", "attempts", "response_status"}}, + {"queue depth", QueueDepthRouteDimensions, + []string{"destination_id", "delivery_group"}}, + {"pending timeseries", PendingTimeseriesRouteDimensions, + []string{"destination_id"}}, + {"events by issue", EventsByIssueRouteDimensions, + []string{"issue_id", "source_id", "destination_id", "webhook_id"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.ElementsMatch(t, tt.want, tt.actual) + }) + } +} + +// TestEventMetricsDimensionsIsTheUnionOfItsRoutes guards the list `metrics +// events` advertises. It fans out over four endpoints, so it offers the union +// and narrows per route at run time - the same shape as its filters. Building +// the union by hand is how it came to claim issue_id was a plain dimension +// while omitting error_code, cli_id and the rest. +func TestEventMetricsDimensionsIsTheUnionOfItsRoutes(t *testing.T) { + var all []string + all = append(all, DefaultEventRouteDimensions...) + all = append(all, QueueDepthRouteDimensions...) + all = append(all, PendingTimeseriesRouteDimensions...) + all = append(all, EventsByIssueRouteDimensions...) + + seen := map[string]bool{} + for _, d := range all { + seen[d] = true + } + for _, d := range EventMetricsDimensionValues { + assert.True(t, seen[d], "%q belongs to no events route", d) + } + assert.Len(t, EventMetricsDimensionValues, len(seen), "the union must be complete and free of duplicates") +} + +// TestRejectUnsupportedDimensions covers the gate itself, including the API's +// cross-field rule: grouping by delivery_group without a destination filter is +// a 422, and that is the release's headline feature looking broken. +func TestRejectUnsupportedDimensions(t *testing.T) { + tests := []struct { + name string + params MetricsQueryParams + allowed []string + wantErr bool + contains []string + }{ + { + name: "no dimensions is always fine", + params: MetricsQueryParams{}, + allowed: PendingTimeseriesRouteDimensions, + }, + { + name: "a dimension the route defines passes", + params: MetricsQueryParams{Dimensions: []string{"destination_id"}}, + allowed: PendingTimeseriesRouteDimensions, + }, + { + name: "a dimension the route does not define is named", + params: MetricsQueryParams{Dimensions: []string{"status"}}, + allowed: PendingTimeseriesRouteDimensions, + wantErr: true, + contains: []string{"--dimensions", "status", "test route", "destination_id"}, + }, + { + name: "delivery_group needs a destination filter", + params: MetricsQueryParams{Dimensions: []string{"delivery_group"}}, + allowed: DefaultEventRouteDimensions, + wantErr: true, + contains: []string{"delivery_group", "--destination-id"}, + }, + { + name: "delivery_group with a destination filter passes", + params: MetricsQueryParams{Dimensions: []string{"delivery_group"}, DestinationID: "des_1"}, + allowed: DefaultEventRouteDimensions, + }, + { + name: "the caller's connection_id spelling is accepted", + params: MetricsQueryParams{Dimensions: []string{"connection_id"}}, + allowed: DefaultEventRouteDimensions, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := RejectUnsupportedDimensions(tt.params, tt.allowed, "test route", CLIFilterNames, "--dimensions") + if !tt.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + for _, want := range tt.contains { + assert.Contains(t, err.Error(), want) + } + }) + } +} + +// TestDimensionListSpellsConnectionForTheCaller covers the one rename between +// the API and both callers: the API groups by webhook_id, and the CLI and MCP +// both take connection_id and map it. An error listing the API spelling would +// send the caller to a name their own tool does not accept. +func TestDimensionListSpellsConnectionForTheCaller(t *testing.T) { + got := DimensionList([]string{"source_id", "webhook_id"}) + assert.Equal(t, "source_id, connection_id", got) +} + +// TestMetricsMeasuresMatchTheEndpointSchemas pins the per-action measure lists +// the CLI's --help and the MCP schema both read. The MCP tool replaced four +// accurate lists with one that was wrong on three of the four actions. +func TestMetricsMeasuresMatchTheEndpointSchemas(t *testing.T) { + assert.ElementsMatch(t, + []string{"count", "accepted_count", "rejected_count", "discarded_count", "avg_events_per_request", "avg_ignored_per_request"}, + RequestMetricsMeasureValues) + assert.ElementsMatch(t, + []string{"count", "successful_count", "failed_count", "delivered_count", "error_rate", "response_latency_avg", "response_latency_max", "response_latency_p95", "response_latency_p99", "delivery_latency_avg"}, + AttemptMetricsMeasureValues) + assert.ElementsMatch(t, + []string{"count", "successful_count", "failed_count", "error_rate", "error_count", "warn_count", "info_count", "debug_count"}, + TransformationMetricsMeasureValues) + + // count is the only measure all four actions share; the old MCP schema + // advertised three more as "common". + for _, action := range [][]string{ + EventMetricsMeasureValues, RequestMetricsMeasureValues, + AttemptMetricsMeasureValues, TransformationMetricsMeasureValues, + } { + assert.Contains(t, action, "count") + } + assert.NotContains(t, RequestMetricsMeasureValues, "successful_count") + assert.NotContains(t, RequestMetricsMeasureValues, "failed_count") + assert.NotContains(t, RequestMetricsMeasureValues, "error_count") + assert.NotContains(t, EventMetricsMeasureValues, "error_count") + assert.NotContains(t, AttemptMetricsMeasureValues, "error_count") +} + +// TestEveryEventMeasureRoutes keeps the measure vocabulary and the routing table +// in step: a measure advertised on `events` but absent from the routing table +// goes to the default endpoint by accident rather than by decision. +func TestEveryEventMeasureRoutes(t *testing.T) { + for _, m := range EventMetricsMeasureValues { + _, known := eventMeasureRoutes[m] + assert.True(t, known, "advertised measure %q has no route", m) + } +} diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index 759dd13c..4917ec99 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -1,6 +1,9 @@ package hookdeck -import "fmt" +import ( + "fmt" + "strings" +) // MetricsFilters names the filters a metrics endpoint actually honours. // @@ -86,24 +89,148 @@ var MCPFilterNames = MetricsFilterNames{ DeliveryGroup: "delivery_group", } -// Dimension and status vocabularies per metrics route. These differ sharply -// between endpoints, so --help must not advertise one generic list: naming a -// dimension the route does not accept sends the user into an API 422. -const ( - RequestMetricsDimensions = "source_id, rejection_cause, status, bulk_retry_ids, events_count, ignored_count" - AttemptMetricsDimensions = "destination_id, delivery_group, event_id, status, error_code, bulk_retry_id, trigger" - TransformationMetricsDimensions = "transformation_id, webhook_id, log_level, issue_id" - EventMetricsDimensions = "source_id, destination_id, connection_id, delivery_group, status, issue_id" +// Dimensions honoured by each metrics endpoint, taken from the API's OpenAPI +// document (the `dimensions` enum of each GET /metrics/* operation). +// +// These differ sharply between endpoints, so neither --help nor the MCP tool +// schema may advertise one generic list: naming a dimension the route does not +// accept sends the caller into an API 422. Filters have been gated against a +// shared matrix for a while; dimensions were not gated at all, which is how +// `dimensions: ["delivery_group"]` and `dimensions: ["status"]` on pending +// event metrics reached the API as raw 422s. +// +// Spelled as the API spells them: the connection dimension is webhook_id here, +// and both callers map their own connection_id onto it before validating. +var ( + RequestMetricsDimensionValues = []string{"source_id", "rejection_cause", "status", "bulk_retry_ids", "events_count", "ignored_count"} + AttemptMetricsDimensionValues = []string{"destination_id", "delivery_group", "event_id", "status", "error_code", "bulk_retry_id", "trigger"} + TransformationMetricsDimensionValues = []string{"transformation_id", "webhook_id", "log_level", "issue_id"} + + // The four endpoints `events` can route to. As with filters, the caller + // advertises the union and narrows per route. + DefaultEventRouteDimensions = []string{"source_id", "destination_id", "webhook_id", "delivery_group", "status", "error_code", "event_data_id", "cli_id", "cli_user_id", "attempts", "response_status"} + QueueDepthRouteDimensions = []string{"destination_id", "delivery_group"} + PendingTimeseriesRouteDimensions = []string{"destination_id"} + EventsByIssueRouteDimensions = []string{"issue_id", "source_id", "destination_id", "webhook_id"} + + EventMetricsDimensionValues = unionValues( + DefaultEventRouteDimensions, + QueueDepthRouteDimensions, + PendingTimeseriesRouteDimensions, + EventsByIssueRouteDimensions, + ) +) + +// Dimension vocabularies rendered for a caller, in the caller's spelling. +var ( + RequestMetricsDimensions = DimensionList(RequestMetricsDimensionValues) + AttemptMetricsDimensions = DimensionList(AttemptMetricsDimensionValues) + TransformationMetricsDimensions = DimensionList(TransformationMetricsDimensionValues) + EventMetricsDimensions = DimensionList(EventMetricsDimensionValues) +) + +// Measures honoured by each metrics action, from the same OpenAPI document. +// +// `events` additionally carries the route-selecting spellings the CLI and MCP +// invented - "pending" and "queue_depth" - which are translated before the +// request is sent. Advertised, not enforced: the API owns the enum, so a new +// measure it gains still reaches it rather than being refused here. +var ( + EventMetricsMeasureValues = []string{"count", "successful_count", "failed_count", "scheduled_count", "paused_count", "error_rate", "avg_attempts", "scheduled_retry_count", "max_count_per_second", "pending", "queue_depth", "max_depth", "max_age"} + RequestMetricsMeasureValues = []string{"count", "accepted_count", "rejected_count", "discarded_count", "avg_events_per_request", "avg_ignored_per_request"} + AttemptMetricsMeasureValues = []string{"count", "successful_count", "failed_count", "delivered_count", "error_rate", "response_latency_avg", "response_latency_max", "response_latency_p95", "response_latency_p99", "delivery_latency_avg"} + TransformationMetricsMeasureValues = []string{"count", "successful_count", "failed_count", "error_rate", "error_count", "warn_count", "info_count", "debug_count"} +) + +// Measure vocabularies rendered for a caller. +var ( + EventMetricsMeasures = ValueList(EventMetricsMeasureValues) + RequestMetricsMeasures = ValueList(RequestMetricsMeasureValues) + AttemptMetricsMeasures = ValueList(AttemptMetricsMeasureValues) + TransformationMetricsMeasures = ValueList(TransformationMetricsMeasureValues) ) // Status vocabularies. Request events are accepted or rejected at the edge; -// events and attempts carry a delivery status. +// events and attempts carry a delivery status. Transformation metrics have no +// status filter at all. const ( - RequestStatusValues = "ACCEPTED, REJECTED" - EventStatusValues = "SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED" - AttemptStatusValues = "SUCCESSFUL, FAILED" + RequestStatusValues = "ACCEPTED, REJECTED" + EventStatusValues = "SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED" + AttemptStatusValues = "SUCCESSFUL, FAILED" + TransformationStatusValues = "" ) +// ValueList renders a vocabulary for display in --help or a tool schema. +func ValueList(values []string) string { + return strings.Join(values, ", ") +} + +// DimensionList renders a dimension vocabulary in the caller's spelling: the +// API's webhook_id is connection_id to both the CLI and MCP, which map it on +// the way in. +func DimensionList(values []string) string { + out := make([]string, len(values)) + for i, v := range values { + if v == "webhook_id" { + v = "connection_id" + } + out[i] = v + } + return strings.Join(out, ", ") +} + +// unionValues concatenates vocabularies, keeping first-seen order and dropping +// duplicates, so a union list cannot drift from the routes it is built from. +func unionValues(lists ...[]string) []string { + var out []string + seen := map[string]bool{} + for _, list := range lists { + for _, v := range list { + if seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + } + return out +} + +func containsValue(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} + +// RejectUnsupportedDimensions reports the first dimension the endpoint does not +// define, plus the API's one cross-field rule: grouping by delivery_group needs +// a destination_id filter. +// +// Without this the caller sees a raw 422 for something the tool appeared to +// offer - and on `dimensions: ["delivery_group"]` that is the release's +// headline feature looking broken. dimensionsName is the caller's own spelling +// of the argument, so a CLI user reads "--dimensions" and an MCP client reads +// "dimensions". +func RejectUnsupportedDimensions(params MetricsQueryParams, allowed []string, route string, names MetricsFilterNames, dimensionsName string) error { + for _, d := range params.Dimensions { + if d == "connection_id" { + d = "webhook_id" + } + if !containsValue(allowed, d) { + return fmt.Errorf("%s %q is not supported by %s; that route groups by: %s", + dimensionsName, d, route, DimensionList(allowed)) + } + } + if containsValue(params.Dimensions, "delivery_group") && params.DestinationID == "" { + return fmt.Errorf("%s delivery_group requires %s; the API rejects grouping by delivery group without a destination filter", + dimensionsName, names.DestinationID) + } + return nil +} + // TranslateQueueDepthMeasures maps the CLI's and MCP's "queue_depth" spelling // onto the API's "max_depth", dropping a duplicate if both were requested. The // queue-depth endpoint accepts max_depth and max_age only. @@ -128,6 +255,7 @@ const ( EventRouteDefault = "event metrics" EventRouteQueueDepth = "queue depth metrics" EventRoutePending = "pending event metrics" + EventRouteByIssue = "per-issue event metrics" ) // eventMeasureRoutes maps every measure `metrics events` advertises onto the API @@ -146,6 +274,7 @@ var eventMeasureRoutes = map[string]string{ "error_rate": EventRouteDefault, "avg_attempts": EventRouteDefault, "scheduled_retry_count": EventRouteDefault, + "max_count_per_second": EventRouteDefault, "queue_depth": EventRouteQueueDepth, "max_depth": EventRouteQueueDepth, @@ -154,6 +283,25 @@ var eventMeasureRoutes = map[string]string{ "pending": EventRoutePending, } +// eventDimensionRoutes maps a dimension onto the endpoint it selects. Only +// issue_id selects a route of its own; every other dimension is grouped by +// whichever endpoint the measures choose. +var eventDimensionRoutes = map[string]string{ + "issue_id": EventRouteByIssue, +} + +// firstRoutedMeasure returns the first measure that selects an endpoint, and the +// endpoint it selects. ("", "") means the measures do not decide the route — the +// request falls through to whatever the dimensions select, or to the default. +func firstRoutedMeasure(measures []string) (string, string) { + for _, m := range measures { + if route, ok := eventMeasureRoutes[m]; ok { + return m, route + } + } + return "", "" +} + // RejectMixedMeasureRoutes refuses a measure list that spans more than one // events-metrics endpoint. // @@ -182,3 +330,53 @@ func RejectMixedMeasureRoutes(measures []string, measuresName string) error { } return nil } + +// RejectCrossRouteEventQuery refuses an events query whose parts select more +// than one API endpoint. +// +// `metrics events` fans out over four endpoints and calls exactly one of them, +// choosing it from the measures, then the issue_id dimension, then the issue +// filter. First match wins, so a request naming parts of two routes is answered +// from one of them and the rest of the question is dropped without a word: a +// queue-depth measure shadowed the issue_id dimension entirely (#407), and +// "pending" shadows it the same way. One route's numbers returned under another +// route's question are worse than no answer, so name both routes and refuse. +// +// It subsumes RejectMixedMeasureRoutes, which is the same rule applied within +// the measure list. Callers should use this and not both. +// +// measuresName and dimensionsName are the caller's own spellings of the +// arguments, and names supplies the same for the filters, so a CLI user reads +// "--measures" and an MCP client reads "measures". +func RejectCrossRouteEventQuery(params MetricsQueryParams, measuresName, dimensionsName string, names MetricsFilterNames) error { + if err := RejectMixedMeasureRoutes(params.Measures, measuresName); err != nil { + return err + } + + measure, measureRoute := firstRoutedMeasure(params.Measures) + // The default route is the one every dimension refines rather than + // contradicts: `--measures count --dimensions issue_id` is a per-issue count, + // which is exactly what the by-issue endpoint answers. + if measureRoute == "" || measureRoute == EventRouteDefault { + return nil + } + + conflict := func(selector, route string) error { + return fmt.Errorf("%s %q (%s) cannot be combined with %s (%s): these are separate API endpoints, so ask for one route at a time", + measuresName, measure, measureRoute, selector, route) + } + + for _, d := range params.Dimensions { + route, selects := eventDimensionRoutes[d] + if selects && route != measureRoute { + return conflict(fmt.Sprintf("%s %q", dimensionsName, d), route) + } + } + // The filter selects the by-issue route on its own, so it conflicts on its + // own too — and saying which two routes were asked for is more use than + // reporting it as a filter the endpoint happens to ignore. + if params.IssueID != "" && measureRoute != EventRouteByIssue { + return conflict(names.IssueID, EventRouteByIssue) + } + return nil +} From 1ac27c115b90f7fd0afa7f5ee727b615713ded19 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 21:45:26 +0100 Subject: [PATCH 13/16] fix: forward the filters the request events route actually honours hookdeck_requests action "events" dropped source_id before the request was built, so "the events of req_X that came from source Y" answered with every event of the request. That was briefed as "the API ignores it" and the previous commit acted on it, refusing the argument. The brief was wrong: GET /requests/{id}/events declares the whole /events filter set and honours it -- verified live, a bogus source returns zero rows and the real one returns the matching row. So the refusal is replaced with forwarding for everything the route declares, and `gateway request events` gains the matching flags. It offered five; it now offers the flag set of `gateway event list`, same names, same wording, because it queries the same collection narrowed to one request. --id is the one flag left off: this command already takes the request ID as its argument, and a second --id meaning "event IDs" beside it reads as the request's. Still refused on events, and still tested: verified, rejection_cause and ingested_* describe the edge decision, which the sub-resource has no parameter for. Confirmed against the live route -- it answers 200 with unfiltered rows for a parameter it does not declare, which is exactly the silent-wrong-answer the guard exists for. status was the one real design problem. It means ACCEPTED/REJECTED on list and SCHEDULED/QUEUED/.../CANCELLED on events, and MCP has one flat property per tool. The description now names both vocabularies, the shape hookdeck_metrics already uses for the four its own status argument carries, rather than inventing a second spelling the CLI has no equivalent of. The handler checks the value against the action's own list: the API does 422 on an out-of-enum status, but that message names only the enum of the route it was sent to, never the sibling action that takes the value. Matching ignores case and sends the API's own spelling, which the API itself will not do -- it 422s "successful" against the upper-case enum. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- REFERENCE.md | 26 +++ pkg/cmd/request_events.go | 125 ++++++++++++- pkg/cmd/request_events_filters_test.go | 138 ++++++++++++++ pkg/gateway/mcp/tool_actions.go | 88 +++++++-- pkg/gateway/mcp/tool_actions_test.go | 13 +- pkg/gateway/mcp/tool_help.go | 47 +++-- pkg/gateway/mcp/tool_requests.go | 36 +++- .../mcp/tool_requests_events_filters_test.go | 175 ++++++++++++++++++ pkg/gateway/mcp/tools.go | 91 ++++++--- pkg/hookdeck/metrics_filters.go | 15 +- pkg/hookdeck/status.go | 50 +++++ pkg/hookdeck/status_test.go | 60 ++++++ 12 files changed, 797 insertions(+), 67 deletions(-) create mode 100644 pkg/cmd/request_events_filters_test.go create mode 100644 pkg/gateway/mcp/tool_requests_events_filters_test.go create mode 100644 pkg/hookdeck/status.go create mode 100644 pkg/hookdeck/status_test.go diff --git a/REFERENCE.md b/REFERENCE.md index 8692f85a..9128c936 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1802,6 +1802,9 @@ hookdeck gateway request retry req_abc123 --connection-ids web_1,web_2 List events (deliveries) created from a request. +Filters match `hookdeck gateway event list`: this command queries the same event +collection, narrowed to one request. + **Usage:** ```bash @@ -1812,16 +1815,39 @@ hookdeck gateway request events [flags] | Flag | Type | Description | |------|------|-------------| +| `--attempts` | `string` | Filter by number of attempts (integer or operators) | +| `--body` | `string` | Filter by body (JSON string) | +| `--cli-id` | `string` | Filter by CLI ID | +| `--connection-id` | `string` | Filter by connection ID | +| `--created-after` | `string` | Filter events created after (ISO date-time) | +| `--created-before` | `string` | Filter events created before (ISO date-time) | | `--delivery-group` | `string` | Filter by delivery group | +| `--destination-id` | `string` | Filter by destination ID | +| `--dir` | `string` | Sort direction (asc, desc) | +| `--error-code` | `string` | Filter by error code | +| `--headers` | `string` | Filter by headers (JSON string) | +| `--issue-id` | `string` | Filter by issue ID | +| `--last-attempt-at-after` | `string` | Filter by last_attempt_at after (ISO date-time) | +| `--last-attempt-at-before` | `string` | Filter by last_attempt_at before (ISO date-time) | | `--limit` | `int` | Limit number of results (default "100") | | `--next` | `string` | Pagination cursor for next page | +| `--order-by` | `string` | Sort key (e.g. created_at) | | `--output` | `string` | Output format (json) | +| `--parsed-query` | `string` | Filter by parsed query (JSON string) | +| `--path` | `string` | Filter by path | | `--prev` | `string` | Pagination cursor for previous page | +| `--response-status` | `string` | Filter by HTTP response status (e.g. 200, 500) | +| `--source-id` | `string` | Filter by source ID | +| `--status` | `string` | Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED) | +| `--successful-at-after` | `string` | Filter by successful_at after (ISO date-time) | +| `--successful-at-before` | `string` | Filter by successful_at before (ISO date-time) | **Examples:** ```bash hookdeck gateway request events req_abc123 +hookdeck gateway request events req_abc123 --status FAILED +hookdeck gateway request events req_abc123 --destination-id des_abc123 ``` ### hookdeck gateway request ignored-events diff --git a/pkg/cmd/request_events.go b/pkg/cmd/request_events.go index 07dcc5bb..cc6390a6 100644 --- a/pkg/cmd/request_events.go +++ b/pkg/cmd/request_events.go @@ -20,7 +20,28 @@ type requestEventsCmd struct { prev string output string - deliveryGroup string + connectionID string + sourceID string + destinationID string + deliveryGroup string + status string + attempts string + responseStatus string + errorCode string + cliID string + issueID string + createdAfter string + createdBefore string + successfulAfter string + successfulBefore string + lastAttemptAfter string + lastAttemptBefore string + headers string + body string + path string + parsedQuery string + orderBy string + dir string } func newRequestEventsCmd() *requestEventsCmd { @@ -32,15 +53,49 @@ func newRequestEventsCmd() *requestEventsCmd { Short: "List events for a request", Long: `List events (deliveries) created from a request. +Filters match ` + "`hookdeck gateway event list`" + `: this command queries the same event +collection, narrowed to one request. + Examples: - hookdeck gateway request events req_abc123`, + hookdeck gateway request events req_abc123 + hookdeck gateway request events req_abc123 --status FAILED + hookdeck gateway request events req_abc123 --destination-id des_abc123`, RunE: rc.runRequestEventsCmd, } + // GET /requests/{id}/events declares the same query parameters as GET + // /events, so these are the flags of `gateway event list`, spelled and + // worded the same way - the two commands are learnt together, and a + // filter that exists on one and not the other reads as unsupported. + // + // `event list --id` is the one flag deliberately left off: this command + // already takes the request ID as its argument, so a second --id meaning + // "event IDs" right beside it would be read as the request's. + rc.cmd.Flags().StringVar(&rc.connectionID, "connection-id", "", "Filter by connection ID") + rc.cmd.Flags().StringVar(&rc.sourceID, "source-id", "", "Filter by source ID") + rc.cmd.Flags().StringVar(&rc.destinationID, "destination-id", "", "Filter by destination ID") + rc.cmd.Flags().StringVar(&rc.deliveryGroup, "delivery-group", "", "Filter by delivery group") + rc.cmd.Flags().StringVar(&rc.status, "status", "", "Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED)") + rc.cmd.Flags().StringVar(&rc.attempts, "attempts", "", "Filter by number of attempts (integer or operators)") + rc.cmd.Flags().StringVar(&rc.responseStatus, "response-status", "", "Filter by HTTP response status (e.g. 200, 500)") + rc.cmd.Flags().StringVar(&rc.errorCode, "error-code", "", "Filter by error code") + rc.cmd.Flags().StringVar(&rc.cliID, "cli-id", "", "Filter by CLI ID") + rc.cmd.Flags().StringVar(&rc.issueID, "issue-id", "", "Filter by issue ID") + rc.cmd.Flags().StringVar(&rc.createdAfter, "created-after", "", "Filter events created after (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.createdBefore, "created-before", "", "Filter events created before (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.successfulAfter, "successful-at-after", "", "Filter by successful_at after (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.successfulBefore, "successful-at-before", "", "Filter by successful_at before (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.lastAttemptAfter, "last-attempt-at-after", "", "Filter by last_attempt_at after (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.lastAttemptBefore, "last-attempt-at-before", "", "Filter by last_attempt_at before (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.headers, "headers", "", "Filter by headers (JSON string)") + rc.cmd.Flags().StringVar(&rc.body, "body", "", "Filter by body (JSON string)") + rc.cmd.Flags().StringVar(&rc.path, "path", "", "Filter by path") + rc.cmd.Flags().StringVar(&rc.parsedQuery, "parsed-query", "", "Filter by parsed query (JSON string)") + rc.cmd.Flags().StringVar(&rc.orderBy, "order-by", "", "Sort key (e.g. created_at)") + rc.cmd.Flags().StringVar(&rc.dir, "dir", "", "Sort direction (asc, desc)") rc.cmd.Flags().IntVar(&rc.limit, "limit", 100, "Limit number of results") rc.cmd.Flags().StringVar(&rc.next, "next", "", "Pagination cursor for next page") rc.cmd.Flags().StringVar(&rc.prev, "prev", "", "Pagination cursor for previous page") - rc.cmd.Flags().StringVar(&rc.deliveryGroup, "delivery-group", "", "Filter by delivery group") rc.cmd.Flags().StringVar(&rc.output, "output", "", "Output format (json)") return rc @@ -61,9 +116,73 @@ func (rc *requestEventsCmd) runRequestEventsCmd(cmd *cobra.Command, args []strin if rc.prev != "" { params["prev"] = rc.prev } + // Same parameter names and same date-bracket mapping as `event list`. + if rc.connectionID != "" { + params["webhook_id"] = rc.connectionID + } + if rc.sourceID != "" { + params["source_id"] = rc.sourceID + } + if rc.destinationID != "" { + params["destination_id"] = rc.destinationID + } if rc.deliveryGroup != "" { params["delivery_group"] = rc.deliveryGroup } + if rc.status != "" { + params["status"] = rc.status + } + if rc.attempts != "" { + params["attempts"] = rc.attempts + } + if rc.responseStatus != "" { + params["response_status"] = rc.responseStatus + } + if rc.errorCode != "" { + params["error_code"] = rc.errorCode + } + if rc.cliID != "" { + params["cli_id"] = rc.cliID + } + if rc.issueID != "" { + params["issue_id"] = rc.issueID + } + if rc.createdAfter != "" { + params["created_at[gte]"] = rc.createdAfter + } + if rc.createdBefore != "" { + params["created_at[lte]"] = rc.createdBefore + } + if rc.successfulAfter != "" { + params["successful_at[gte]"] = rc.successfulAfter + } + if rc.successfulBefore != "" { + params["successful_at[lte]"] = rc.successfulBefore + } + if rc.lastAttemptAfter != "" { + params["last_attempt_at[gte]"] = rc.lastAttemptAfter + } + if rc.lastAttemptBefore != "" { + params["last_attempt_at[lte]"] = rc.lastAttemptBefore + } + if rc.headers != "" { + params["headers"] = rc.headers + } + if rc.body != "" { + params["body"] = rc.body + } + if rc.path != "" { + params["path"] = rc.path + } + if rc.parsedQuery != "" { + params["parsed_query"] = rc.parsedQuery + } + if rc.orderBy != "" { + params["order_by"] = rc.orderBy + } + if rc.dir != "" { + params["dir"] = rc.dir + } resp, err := client.GetRequestEvents(ctx, requestID, params) if err != nil { diff --git a/pkg/cmd/request_events_filters_test.go b/pkg/cmd/request_events_filters_test.go new file mode 100644 index 00000000..d66096f9 --- /dev/null +++ b/pkg/cmd/request_events_filters_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// requestEventsIDFlag is the one `gateway event list` flag this command does +// not offer. `gateway request events` already takes the request ID as its +// argument, so a --id meaning "event IDs" standing right next to it would be +// read as the request's. The API parameter exists; the spelling is what does +// not survive the move. +const requestEventsIDFlag = "id" + +// TestRequestEventsOffersTheEventListFilters guards the flag set. +// +// GET /requests/{id}/events declares the same query parameters as GET /events, +// so this command is `gateway event list` narrowed to one request and has to +// offer the same filters under the same names. It used to offer five, so +// "which events of this request failed" had no answer here at all and the +// filters that did exist read as the complete set. +func TestRequestEventsOffersTheEventListFilters(t *testing.T) { + eventList := newEventListCmd().cmd + requestEvents := newRequestEventsCmd().cmd + + var missing []string + eventList.Flags().VisitAll(func(f *pflag.Flag) { + if f.Name == requestEventsIDFlag { + return + } + got := requestEvents.Flags().Lookup(f.Name) + if got == nil { + missing = append(missing, f.Name) + return + } + assert.Equal(t, f.Usage, got.Usage, + "--%s should read the same in both commands; they are learnt together", f.Name) + }) + + assert.Empty(t, missing, + "the route honours these on /requests/{id}/events, so the command must offer them") +} + +// TestRequestEventsForwardsFiltersToTheAPI is the other half: a flag that never +// reaches the request is worse than no flag, because the unfiltered rows come +// back looking filtered. The renamed parameters are where that hides - +// --connection-id is webhook_id, and the date bounds become bracket keys. +func TestRequestEventsForwardsFiltersToTheAPI(t *testing.T) { + tests := []struct { + flag string + value string + param string + want string + }{ + {"source-id", "src_123", "source_id", "src_123"}, + {"connection-id", "web_123", "webhook_id", "web_123"}, + {"destination-id", "des_123", "destination_id", "des_123"}, + {"delivery-group", "dg_123", "delivery_group", "dg_123"}, + {"status", "FAILED", "status", "FAILED"}, + {"attempts", "3", "attempts", "3"}, + {"response-status", "500", "response_status", "500"}, + {"error-code", "TIMEOUT", "error_code", "TIMEOUT"}, + {"cli-id", "cli_123", "cli_id", "cli_123"}, + {"issue-id", "iss_123", "issue_id", "iss_123"}, + {"created-after", "2025-01-01T00:00:00Z", "created_at[gte]", "2025-01-01T00:00:00Z"}, + {"created-before", "2025-02-01T00:00:00Z", "created_at[lte]", "2025-02-01T00:00:00Z"}, + {"successful-at-after", "2025-01-01T00:00:00Z", "successful_at[gte]", "2025-01-01T00:00:00Z"}, + {"successful-at-before", "2025-02-01T00:00:00Z", "successful_at[lte]", "2025-02-01T00:00:00Z"}, + {"last-attempt-at-after", "2025-01-01T00:00:00Z", "last_attempt_at[gte]", "2025-01-01T00:00:00Z"}, + {"last-attempt-at-before", "2025-02-01T00:00:00Z", "last_attempt_at[lte]", "2025-02-01T00:00:00Z"}, + {"headers", `{"x-trace":"1"}`, "headers", `{"x-trace":"1"}`}, + {"body", `{"type":"ping"}`, "body", `{"type":"ping"}`}, + {"path", "/hook", "path", "/hook"}, + {"parsed-query", `{"q":"1"}`, "parsed_query", `{"q":"1"}`}, + {"order-by", "created_at", "order_by", "created_at"}, + {"dir", "asc", "dir", "asc"}, + {"next", "cur_next", "next", "cur_next"}, + {"prev", "cur_prev", "prev", "cur_prev"}, + } + + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + var query url.Values + var path string + server := requestEventsStub(t, &path, &query) + + rc := newRequestEventsCmd() + require.NoError(t, rc.cmd.Flags().Set(tt.flag, tt.value)) + require.NoError(t, runRequestEventsAgainst(t, server, rc, "req_1")) + + require.Equal(t, hookdeck.APIPathPrefix+"/requests/req_1/events", path) + assert.Equal(t, tt.want, query.Get(tt.param), + "--%s must reach the API as %s", tt.flag, tt.param) + }) + } +} + +// requestEventsStub serves the events sub-resource and records what it was +// asked for. +func requestEventsStub(t *testing.T, path *string, query *url.Values) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *path = r.URL.Path + q := r.URL.Query() + *query = q + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(hookdeck.EventListResponse{}) + })) + t.Cleanup(server.Close) + return server +} + +// runRequestEventsAgainst points the command's client at the stub. The API +// client is a process-wide singleton, so it has to be reset around each run. +func runRequestEventsAgainst(t *testing.T, server *httptest.Server, rc *requestEventsCmd, requestID string) error { + t.Helper() + old := Config + t.Cleanup(func() { Config = old }) + config.ResetAPIClientForTesting() + t.Cleanup(config.ResetAPIClientForTesting) + + Config = config.Config{} + Config.APIBaseURL = server.URL + Config.Profile.APIKey = "sk_test_123456789012" + Config.Profile.ProjectId = "proj_1" + + return rc.runRequestEventsCmd(rc.cmd, []string{requestID}) +} diff --git a/pkg/gateway/mcp/tool_actions.go b/pkg/gateway/mcp/tool_actions.go index 718365fb..b317128d 100644 --- a/pkg/gateway/mcp/tool_actions.go +++ b/pkg/gateway/mcp/tool_actions.go @@ -4,25 +4,34 @@ import ( "fmt" "sort" "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) // Per-action argument matrices for the multi-action tools. // // The CLI keeps one flag set per subcommand, so `gateway request list` simply -// has no --delivery-group and `gateway request events` has no --source-id. The -// MCP layer flattens those subcommands into one tool with one flat schema, and -// that precision is lost: a filter meant for a sibling action is accepted, -// never forwarded, and the caller reads an unfiltered result as a filtered one. -// Every other filter narrows to zero rows on a bogus value, so nothing in the -// response reveals the drop. +// has no --delivery-group. The MCP layer flattens those subcommands into one +// tool with one flat schema, and that precision is lost: a filter meant for a +// sibling action is accepted, never forwarded, and the caller reads an +// unfiltered result as a filtered one. Every other filter narrows to zero rows +// on a bogus value, so nothing in the response reveals the drop. // // These maps re-impose the per-subcommand precision, naming the arguments each -// action actually forwards to the API. The refusal wording matches the metrics -// tool's, which has guarded the same hazard for a while. +// action actually forwards to the API. Membership is decided by what the route +// declares in https://api.hookdeck.com/2026-09-01/openapi, not by what the +// handler happens to send today: an argument the route honours belongs here and +// must be forwarded, and only one it does not is refused. The refusal wording +// matches the metrics tool's, which has guarded the same hazard for a while. var ( // requestsActionArgs mirrors the flags of `hookdeck gateway request `. - // delivery_group is on events only - the /requests collection has no such + // + // GET /requests/{id}/events declares the same filter set as GET /events, so + // events carries nearly everything list does plus the delivery filters. + // delivery_group stays events-only: the /requests collection has no such // query parameter, so on list the API answers with unfiltered rows. + // rejection_cause, verified and ingested_* are the reverse - they describe + // the edge decision, which the events sub-resource knows nothing about. requestsActionArgs = map[string][]string{ "list": { "id", "source_id", "status", "rejection_cause", "verified", @@ -30,9 +39,16 @@ var ( "body", "headers", "parsed_query", "path", "order_by", "dir", "limit", "next", "prev", }, - "get": {"id"}, - "raw_body": {"id"}, - "events": {"id", "delivery_group", "limit", "next", "prev"}, + "get": {"id"}, + "raw_body": {"id"}, + "events": { + "id", "connection_id", "source_id", "destination_id", "delivery_group", + "status", "attempts", "issue_id", "error_code", "response_status", "cli_id", + "created_after", "created_before", "successful_after", "successful_before", + "last_attempt_after", "last_attempt_before", + "body", "headers", "parsed_query", "path", + "order_by", "dir", "limit", "next", "prev", + }, "ignored_events": {"id", "limit", "next", "prev"}, } @@ -131,3 +147,51 @@ func otherActionsFor(name string, byAction map[string][]string, except string) s sort.Strings(actions) return ". It applies to: " + strings.Join(actions, ", ") } + +// requestsStatusVocabulary is the status enum of the collection each +// hookdeck_requests action queries. +// +// The tool has one flat `status` property and the two actions mean different +// things by it, so the value has to be checked against the action's own +// vocabulary. The API does reject an out-of-enum status - "SUCCESSFUL" on list +// comes back as 422 "status must be one of [accepted, rejected]" - but that +// message names only the route it was sent to, so a caller who reached for the +// wrong action is told the value is wrong rather than that the sibling action +// takes it. Checking here also lets either case through, which the API does +// not: it 422s "successful" against the upper-case enum. +var requestsStatusVocabulary = map[string][]string{ + "list": hookdeck.RequestLogStatusValueList, + "events": hookdeck.EventStatusValueList, +} + +// canonicalRequestsStatus returns the status to send for this action, in the +// API's own spelling, or an error naming the vocabulary the action does take. +func canonicalRequestsStatus(action, value string) (string, error) { + if value == "" { + return "", nil + } + vocabulary, ok := requestsStatusVocabulary[action] + if !ok { + return value, nil + } + if canonical, ok := hookdeck.CanonicalStatusValue(vocabulary, value); ok { + return canonical, nil + } + return "", fmt.Errorf("status %q is not supported by the %s action of hookdeck_requests; it filters by %s%s", + value, action, hookdeck.ValueList(vocabulary), otherStatusVocabularyFor(value, action)) +} + +// otherStatusVocabularyFor points at the action the value does belong to, so a +// caller who reached for the wrong one is told where it works. +func otherStatusVocabularyFor(value, except string) string { + for _, action := range []string{"list", "events"} { + if action == except { + continue + } + if _, ok := hookdeck.CanonicalStatusValue(requestsStatusVocabulary[action], value); ok { + return fmt.Sprintf(". It belongs to the %s action, which filters by %s", + action, hookdeck.ValueList(requestsStatusVocabulary[action])) + } + } + return "" +} diff --git a/pkg/gateway/mcp/tool_actions_test.go b/pkg/gateway/mcp/tool_actions_test.go index d531a840..5fd0994c 100644 --- a/pkg/gateway/mcp/tool_actions_test.go +++ b/pkg/gateway/mcp/tool_actions_test.go @@ -32,11 +32,16 @@ func TestRequestsToolRejectsArgsTheActionDrops(t *testing.T) { // The reported bug: delivery_group on list. The API has no such query // parameter on /requests, so it answered with unfiltered totals. {"list drops delivery_group", "list", "delivery_group", "dg_bogus", "events"}, - // The same shape one action over. - {"events drops source_id", "events", "source_id", "src_bogus", "list"}, - {"events drops status", "events", "status", "SUCCESSFUL", "list"}, - {"events drops created_after", "events", "created_after", "2025-01-01T00:00:00Z", "list"}, + // source_id, status and created_after used to be here. They are not + // dropped: GET /requests/{id}/events declares the /events filter set and + // honours all three, so they are forwarded now and covered by + // TestRequestsEventsForwardsEveryFilterTheRouteDeclares. What stays + // refused on events is what the sub-resource genuinely has no parameter + // for - the three fields describing the edge decision on the request + // itself, which only /requests carries. {"events drops verified", "events", "verified", true, "list"}, + {"events drops rejection_cause", "events", "rejection_cause", "NO_CONNECTION", "list"}, + {"events drops ingested_after", "events", "ingested_after", "2025-01-01T00:00:00Z", "list"}, {"ignored_events drops delivery_group", "ignored_events", "delivery_group", "dg_bogus", "events"}, {"get drops source_id", "get", "source_id", "src_bogus", "list"}, {"raw_body drops limit", "raw_body", "limit", float64(10), "list"}, diff --git a/pkg/gateway/mcp/tool_help.go b/pkg/gateway/mcp/tool_help.go index 171040cd..59aa10d2 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -185,39 +185,58 @@ Parameters: Results are scoped to the active project — call hookdeck_projects first if the user has specified a project. -List supports the same filters as hookdeck gateway request list. +List supports the same filters as hookdeck gateway request list, and events the same +filters as hookdeck gateway event list — the API route behind it, GET /requests/{id}/events, +declares the whole /events filter set, narrowed to one request. Actions: list — List requests with optional filters get — Get a single request by ID raw_body — Get the raw body of a request - events — List events generated from a request + events — List events generated from a request, with optional filters ignored_events — List ignored events for a request Parameters: action (string, required) — list, get, raw_body, events, or ignored_events id (string) — List: filter by request ID(s), comma-separated. Get/raw_body/events/ignored_events: required. - source_id (string) — Filter by source (list) - status (string) — accepted or rejected (list) + source_id (string) — Filter by source (list, events) rejection_cause (string) — Filter by rejection cause (list) verified (boolean) — Filter by verification status (list) - delivery_group (string) — Filter events generated from the request (events action) - -Date range filters (list): + connection_id (string) — Filter by connection, maps to webhook_id (events) + destination_id (string) — Filter by destination (events) + delivery_group (string) — Filter by delivery group (events) + attempts (string) — Filter by attempt count (events) + issue_id (string) — Filter by issue (events) + error_code (string) — Filter by error code (events) + response_status (string) — Filter by HTTP response status (events) + cli_id (string) — Filter by CLI listen session ID (events) + +status (string) — the vocabulary depends on the action, because the two query different +collections. A value from the other action's vocabulary is refused, not sent: the API +returns unfiltered rows for a status it does not recognise. + list — ` + hookdeck.RequestLogStatusValues + ` (what happened to the request at the edge) + events — ` + hookdeck.EventStatusValues + ` (where each delivery is in its lifecycle) + +Date range filters: Use *_after / *_before with ISO 8601 datetimes (e.g. 2026-06-01T00:00:00Z). Do not pass API bracket keys like created_at[gte] in MCP args. - created_after → created_at[gte] (inclusive lower bound) - created_before → created_at[lte] (inclusive upper bound) - ingested_after → ingested_at[gte] - ingested_before → ingested_at[lte] + created_after → created_at[gte] (list, events; inclusive lower bound) + created_before → created_at[lte] (list, events; inclusive upper bound) + ingested_after → ingested_at[gte] (list) + ingested_before → ingested_at[lte] (list) + successful_after → successful_at[gte] (events) + successful_before → successful_at[lte] (events) + last_attempt_after → last_attempt_at[gte] (events) + last_attempt_before → last_attempt_at[lte] (events) Example: {"action":"list","ingested_after":"2026-06-09T12:00:00Z","source_id":"src_abc"} -Payload search (list): +Payload search (list, events): body, headers, parsed_query — Hookdeck JSON filter syntax (object or string). Same as hookdeck listen --filter-body. path — partial URL path match (string) Example: {"action":"list","body":{"type":"charge.succeeded"}} -Pagination and sort (list): - order_by, dir (asc/desc), limit (default 100), next, prev`, +Pagination and sort: + order_by, dir (asc/desc), limit (default 100), next, prev (list, events; ignored_events takes limit/next/prev) + Example: {"action":"events","id":"req_abc","source_id":"src_abc","status":"FAILED"}`, "hookdeck_events": `hookdeck_events — Query events (processed deliveries) diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 66184cb2..406a61ae 100644 --- a/pkg/gateway/mcp/tool_requests.go +++ b/pkg/gateway/mcp/tool_requests.go @@ -50,10 +50,14 @@ func handleRequests(client *hookdeck.Client) mcpsdk.ToolHandler { } func requestsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { + status, err := canonicalRequestsStatus("list", in.String("status")) + if err != nil { + return ErrorResult(err.Error()), nil + } params := make(map[string]string) setIfNonEmpty(params, "id", in.String("id")) setIfNonEmpty(params, "source_id", in.String("source_id")) - setIfNonEmpty(params, "status", in.String("status")) + setIfNonEmpty(params, "status", status) setIfNonEmpty(params, "rejection_cause", in.String("rejection_cause")) setIfNonEmpty(params, "created_at[gte]", in.String("created_after")) setIfNonEmpty(params, "created_at[lte]", in.String("created_before")) @@ -116,11 +120,41 @@ func requestsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mc if id == "" { return ErrorResult("id is required for the events action"), nil } + // GET /requests/{id}/events declares the /events filter set, so everything + // `hookdeck gateway request events` offers is forwarded here. Only + // delivery_group, limit, next and prev used to be: source_id and the rest + // were dropped before the request was built, and a caller who asked for one + // source read every event of the request as that source's. + status, err := canonicalRequestsStatus("events", in.String("status")) + if err != nil { + return ErrorResult(err.Error()), nil + } params := make(map[string]string) + // connection_id maps to webhook_id in the API + setIfNonEmpty(params, "webhook_id", in.String("connection_id")) + setIfNonEmpty(params, "source_id", in.String("source_id")) + setIfNonEmpty(params, "destination_id", in.String("destination_id")) setIfNonEmpty(params, "delivery_group", in.String("delivery_group")) + setIfNonEmpty(params, "status", status) + setIfNonEmpty(params, "attempts", in.String("attempts")) + setIfNonEmpty(params, "issue_id", in.String("issue_id")) + setIfNonEmpty(params, "error_code", in.String("error_code")) + setIfNonEmpty(params, "response_status", in.String("response_status")) + setIfNonEmpty(params, "cli_id", in.String("cli_id")) + setIfNonEmpty(params, "created_at[gte]", in.String("created_after")) + setIfNonEmpty(params, "created_at[lte]", in.String("created_before")) + setIfNonEmpty(params, "successful_at[gte]", in.String("successful_after")) + setIfNonEmpty(params, "successful_at[lte]", in.String("successful_before")) + setIfNonEmpty(params, "last_attempt_at[gte]", in.String("last_attempt_after")) + setIfNonEmpty(params, "last_attempt_at[lte]", in.String("last_attempt_before")) + setIfNonEmpty(params, "order_by", in.String("order_by")) + setIfNonEmpty(params, "dir", in.String("dir")) setInt(params, "limit", in.Int("limit", 0)) setIfNonEmpty(params, "next", in.String("next")) setIfNonEmpty(params, "prev", in.String("prev")) + if err := setPayloadSearchFilters(params, in); err != nil { + return ErrorResult(err.Error()), nil + } result, err := client.GetRequestEvents(ctx, id, params) if err != nil { return ErrorResult(TranslateAPIError(err)), nil diff --git a/pkg/gateway/mcp/tool_requests_events_filters_test.go b/pkg/gateway/mcp/tool_requests_events_filters_test.go new file mode 100644 index 00000000..42440db4 --- /dev/null +++ b/pkg/gateway/mcp/tool_requests_events_filters_test.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestRequestsEventsForwardsEveryFilterTheRouteDeclares pins the forwarding +// half of the events action. +// +// source_id was accepted and then dropped before the request was built, so +// "the events of req_X that came from source Y" returned every event of the +// request. The API was never the problem: GET /requests/{id}/events declares +// the whole /events filter set and honours it. Every argument the action +// advertises has to arrive under the name the API knows it by - the date +// bounds and connection_id are renamed on the way out, which is precisely +// where a silent drop hides. +func TestRequestsEventsForwardsEveryFilterTheRouteDeclares(t *testing.T) { + tests := []struct { + arg string + value any + param string + want string + }{ + {"source_id", "src_123", "source_id", "src_123"}, + {"connection_id", "web_123", "webhook_id", "web_123"}, + {"destination_id", "des_123", "destination_id", "des_123"}, + {"delivery_group", "dg_123", "delivery_group", "dg_123"}, + {"status", "FAILED", "status", "FAILED"}, + {"attempts", "3", "attempts", "3"}, + {"issue_id", "iss_123", "issue_id", "iss_123"}, + {"error_code", "TIMEOUT", "error_code", "TIMEOUT"}, + {"response_status", "500", "response_status", "500"}, + {"cli_id", "cli_123", "cli_id", "cli_123"}, + {"created_after", "2025-01-01T00:00:00Z", "created_at[gte]", "2025-01-01T00:00:00Z"}, + {"created_before", "2025-02-01T00:00:00Z", "created_at[lte]", "2025-02-01T00:00:00Z"}, + {"successful_after", "2025-01-01T00:00:00Z", "successful_at[gte]", "2025-01-01T00:00:00Z"}, + {"successful_before", "2025-02-01T00:00:00Z", "successful_at[lte]", "2025-02-01T00:00:00Z"}, + {"last_attempt_after", "2025-01-01T00:00:00Z", "last_attempt_at[gte]", "2025-01-01T00:00:00Z"}, + {"last_attempt_before", "2025-02-01T00:00:00Z", "last_attempt_at[lte]", "2025-02-01T00:00:00Z"}, + {"body", `{"type":"ping"}`, "body", `{"type":"ping"}`}, + {"headers", `{"x-trace":"1"}`, "headers", `{"x-trace":"1"}`}, + {"parsed_query", `{"q":"1"}`, "parsed_query", `{"q":"1"}`}, + {"path", "/hook", "path", "/hook"}, + {"order_by", "created_at", "order_by", "created_at"}, + {"dir", "asc", "dir", "asc"}, + {"limit", float64(5), "limit", "5"}, + {"next", "cur_next", "next", "cur_next"}, + {"prev", "cur_prev", "prev", "cur_prev"}, + } + + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + var saw string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests/req_1/events": func(w http.ResponseWriter, r *http.Request) { + saw = r.URL.Query().Get(tt.param) + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "events", "id": "req_1", tt.arg: tt.value, + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, tt.want, saw, "%s must reach the API as %s", tt.arg, tt.param) + }) + } +} + +// TestRequestsEventsMatchesTheEventsListFilterSet stops the two from drifting. +// +// GET /requests/{id}/events and GET /events declare the same query parameters, +// so hookdeck_requests action "events" and hookdeck_events action "list" must +// offer the same arguments. A filter added to one and forgotten on the other is +// how the events action ended up with four of them in the first place. +func TestRequestsEventsMatchesTheEventsListFilterSet(t *testing.T) { + events := append([]string{}, requestsActionArgs["events"]...) + list := append([]string{}, eventsActionArgs["list"]...) + sort.Strings(events) + sort.Strings(list) + + // id is in both, but means the request on one and the event on the other; + // the sets still match, and nothing else is exempt. + assert.Equal(t, list, events, + "hookdeck_requests action events and hookdeck_events action list query routes with the same filter set") +} + +// TestRequestsStatusIsValidatedPerAction covers the one argument whose meaning +// changes with the action: list filters requests by what happened at the edge, +// events filters deliveries by lifecycle state. One flat schema property +// carries both vocabularies, so a value from the wrong one is refused here +// instead of coming back as the API's 422, which names only the enum of the +// route it was sent to and never mentions the action that does take it. +func TestRequestsStatusIsValidatedPerAction(t *testing.T) { + forwards := []struct { + name string + action string + path string + value string + want string + }{ + {"event status on events", "events", "/requests/req_1/events", "SUCCESSFUL", "SUCCESSFUL"}, + {"event status is case-insensitive", "events", "/requests/req_1/events", "successful", "SUCCESSFUL"}, + {"request status on list", "list", "/requests", "accepted", "accepted"}, + {"request status is case-insensitive", "list", "/requests", "ACCEPTED", "accepted"}, + } + for _, tt := range forwards { + t.Run(tt.name, func(t *testing.T) { + var saw string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + tt.path: func(w http.ResponseWriter, r *http.Request) { + saw = r.URL.Query().Get("status") + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": tt.action, "id": "req_1", "status": tt.value, + }) + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, tt.want, saw, "status must reach the API in the spelling the enum uses") + }) + } + + rejects := []struct { + name string + action string + value string + // names the vocabulary the value does belong to, so the error can send + // the caller to the action that honours it. + elsewhere string + }{ + {"request status on events", "events", "accepted", "list"}, + {"event status on list", "list", "SUCCESSFUL", "events"}, + } + for _, tt := range rejects { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with a status from the other vocabulary", r.URL.Path) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/requests": fail, + hookdeck.APIPathPrefix + "/requests/req_1/events": fail, + }) + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": tt.action, "id": "req_1", "status": tt.value, + }) + require.True(t, result.IsError, "%s must be refused on the %s action", tt.value, tt.action) + body := textContent(t, result) + assert.Contains(t, body, tt.value) + assert.Contains(t, body, tt.action) + assert.Contains(t, body, tt.elsewhere, "the message should name the action that takes it") + }) + } +} + +// TestRequestsStatusDescriptionNamesBothVocabularies is the schema half of the +// same problem. The description used to read "accepted or rejected (list)" +// while the property is also the events filter, so an MCP client planning a +// call had no way to learn the event vocabulary existed. +func TestRequestsStatusDescriptionNamesBothVocabularies(t *testing.T) { + desc := requestsToolProperties["status"].Desc + assert.Contains(t, desc, hookdeck.RequestLogStatusValues, "the list vocabulary must be named") + assert.Contains(t, desc, hookdeck.EventStatusValues, "the events vocabulary must be named") + assert.Contains(t, desc, "list") + assert.Contains(t, desc, "events") +} diff --git a/pkg/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index d0f69a7b..bffe5728 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -182,27 +182,47 @@ func toolDefs(client *hookdeck.Client) []struct { // requestsToolProperties is the hookdeck_requests schema. It is a package var // so requestsActionArgs can be checked against it: an argument the schema // advertises but no action honours is a filter that would vanish in silence. +// +// The events action queries GET /requests/{id}/events, which declares the whole +// /events filter set, so most of the filters `hookdeck gateway event list` +// offers apply there as well as on list. Each description names the actions the +// argument reaches; anywhere else it is refused rather than dropped. +// +// That route also takes an `id` query parameter filtering by event ID, which +// this tool cannot offer: `id` already carries the request ID the events action +// puts in the path, and one property cannot be both. var requestsToolProperties = map[string]prop{ - "action": {Type: "string", Desc: "Action: list, get, raw_body, events, or ignored_events", Enum: []string{"list", "get", "raw_body", "events", "ignored_events"}}, - "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events"}, - "source_id": {Type: "string", Desc: "Filter by source (list)"}, - "status": {Type: "string", Desc: "Filter by status: accepted or rejected (list)"}, - "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, - "delivery_group": {Type: "string", Desc: "Filter by delivery group (events action only; the /requests collection has no such filter, so list rejects it rather than return unfiltered rows)"}, - "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, - "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, - "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, - "ingested_after": {Type: "string", Desc: "ingested_at lower bound. " + descDateAfter}, - "ingested_before": {Type: "string", Desc: "ingested_at upper bound. " + descDateBefore}, - "body": {Type: "string", Desc: "Filter by request body. " + descJSONFilter}, - "headers": {Type: "string", Desc: "Filter by request headers. " + descJSONFilter}, - "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON. " + descJSONFilter}, - "path": {Type: "string", Desc: descPathFilter}, - "order_by": {Type: "string", Desc: "Sort field (list), e.g. created_at"}, - "dir": {Type: "string", Desc: "Sort direction: asc or desc (list)"}, - "limit": {Type: "integer", Desc: "Max results (list, events, ignored_events)"}, - "next": {Type: "string", Desc: "Next page cursor (list, events, ignored_events)"}, - "prev": {Type: "string", Desc: "Previous page cursor (list, events, ignored_events)"}, + "action": {Type: "string", Desc: "Action: list, get, raw_body, events, or ignored_events", Enum: []string{"list", "get", "raw_body", "events", "ignored_events"}}, + "id": {Type: "string", Desc: "Request ID: filter by ID(s) on list (comma-separated), or required for get/raw_body/events/ignored_events"}, + "source_id": {Type: "string", Desc: "Filter by source (list, events)"}, + "connection_id": {Type: "string", Desc: "Filter by connection (events, maps to webhook_id)"}, + "destination_id": {Type: "string", Desc: "Filter by destination (events)"}, + "delivery_group": {Type: "string", Desc: "Filter by delivery group (events; the /requests collection has no such filter, so list rejects it rather than return unfiltered rows)"}, + "status": {Type: "string", Desc: descRequestsStatus}, + "rejection_cause": {Type: "string", Desc: "Filter by rejection cause (list)"}, + "verified": {Type: "boolean", Desc: "Filter by verification status (list)"}, + "attempts": {Type: "string", Desc: "Filter by attempt count (events). Integer or API operator syntax; pass through as string."}, + "issue_id": {Type: "string", Desc: "Filter by issue (events)"}, + "error_code": {Type: "string", Desc: "Filter by error code (events)"}, + "response_status": {Type: "string", Desc: "Filter by HTTP response status (events)"}, + "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (events)"}, + "created_after": {Type: "string", Desc: "created_at lower bound (list, events). " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound (list, events). " + descDateBefore}, + "ingested_after": {Type: "string", Desc: "ingested_at lower bound (list). " + descDateAfter}, + "ingested_before": {Type: "string", Desc: "ingested_at upper bound (list). " + descDateBefore}, + "successful_after": {Type: "string", Desc: "successful_at lower bound (events). " + descDateAfter}, + "successful_before": {Type: "string", Desc: "successful_at upper bound (events). " + descDateBefore}, + "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound (events). " + descDateAfter}, + "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound (events). " + descDateBefore}, + "body": {Type: "string", Desc: "Filter by body (list: the request body; events: the event payload). " + descJSONFilter}, + "headers": {Type: "string", Desc: "Filter by headers (list, events). " + descJSONFilter}, + "parsed_query": {Type: "string", Desc: "Filter by parsed query string as JSON (list, events). " + descJSONFilter}, + "path": {Type: "string", Desc: descPathFilter + " Applies to list and events."}, + "order_by": {Type: "string", Desc: "Sort field (list, events), e.g. created_at"}, + "dir": {Type: "string", Desc: "Sort direction: asc or desc (list, events)"}, + "limit": {Type: "integer", Desc: "Max results (list, events, ignored_events)"}, + "next": {Type: "string", Desc: "Next page cursor (list, events, ignored_events)"}, + "prev": {Type: "string", Desc: "Previous page cursor (list, events, ignored_events)"}, } // eventsToolProperties is the hookdeck_events schema, held as a var for the @@ -220,12 +240,12 @@ var eventsToolProperties = map[string]prop{ "error_code": {Type: "string", Desc: "Filter by error code (list)"}, "response_status": {Type: "string", Desc: "Filter by HTTP response status (list)"}, "cli_id": {Type: "string", Desc: "Filter by CLI listen session ID (list)"}, - "created_after": {Type: "string", Desc: "created_at lower bound. " + descDateAfter}, - "created_before": {Type: "string", Desc: "created_at upper bound. " + descDateBefore}, - "successful_after": {Type: "string", Desc: "successful_at lower bound. " + descDateAfter}, - "successful_before": {Type: "string", Desc: "successful_at upper bound. " + descDateBefore}, - "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound. " + descDateAfter}, - "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound. " + descDateBefore}, + "created_after": {Type: "string", Desc: "created_at lower bound (list). " + descDateAfter}, + "created_before": {Type: "string", Desc: "created_at upper bound (list). " + descDateBefore}, + "successful_after": {Type: "string", Desc: "successful_at lower bound (list). " + descDateAfter}, + "successful_before": {Type: "string", Desc: "successful_at upper bound (list). " + descDateBefore}, + "last_attempt_after": {Type: "string", Desc: "last_attempt_at lower bound (list). " + descDateAfter}, + "last_attempt_before": {Type: "string", Desc: "last_attempt_at upper bound (list). " + descDateBefore}, "body": {Type: "string", Desc: "Filter by event payload body. " + descJSONFilter}, "headers": {Type: "string", Desc: "Filter by event headers. " + descJSONFilter}, "parsed_query": {Type: "string", Desc: "Filter by parsed query as JSON. " + descJSONFilter}, @@ -246,12 +266,27 @@ type prop struct { } const ( - descDateAfter = "ISO 8601 datetime lower bound (list). Maps to API field[gte]; do not pass bracket keys in MCP args. Combinable with the matching *_before param." - descDateBefore = "ISO 8601 datetime upper bound (list). Maps to API field[lte]; do not pass bracket keys in MCP args." + descDateAfter = "ISO 8601 datetime. Maps to API field[gte]; do not pass bracket keys in MCP args. Combinable with the matching *_before param." + descDateBefore = "ISO 8601 datetime. Maps to API field[lte]; do not pass bracket keys in MCP args." descJSONFilter = "Hookdeck JSON filter (object or string). Same syntax as hookdeck listen --filter-body." descPathFilter = "Partial URL path match (string)." ) +// status is the one hookdeck_requests argument whose vocabulary changes with +// the action: list queries GET /requests, whose statuses describe what happened +// to the request at the edge, and events queries GET /requests/{id}/events, +// whose statuses describe where each delivery is in its lifecycle. One flat +// schema property carries both, so the description has to name both — the same +// shape hookdeck_metrics uses for the four vocabularies its status argument +// carries. Describing only one of them was the defect: a client reading +// "accepted or rejected" had no way to learn the events vocabulary exists. The +// handler checks the value against the action's own list as well, because the +// API's 422 names only the enum of the route it was sent to. +var descRequestsStatus = "Filter by status. The vocabulary differs per action, because the two actions query different collections — " + + "list (the status of the request itself): " + hookdeck.RequestLogStatusValues + "; " + + "events (the delivery status of each event the request produced): " + hookdeck.EventStatusValues + ". " + + "A value from the other action's vocabulary is refused, not sent." + // measures, dimensions and status decide whether a metrics call succeeds, and // each of the four actions has its own vocabulary. One flat schema cannot carry // four enums, so the per-action lists go in the description - built from the diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index 4917ec99..5aba66f1 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -150,12 +150,17 @@ var ( TransformationMetricsMeasures = ValueList(TransformationMetricsMeasureValues) ) -// Status vocabularies. Request events are accepted or rejected at the edge; -// events and attempts carry a delivery status. Transformation metrics have no -// status filter at all. -const ( +// Status vocabularies. Requests are accepted or rejected at the edge; events +// and attempts carry a delivery status. Transformation metrics have no status +// filter at all. +// +// EventStatusValues is rendered from EventStatusValueList (status.go), the list +// the log routes validate a caller's value against, so what is advertised and +// what is accepted cannot drift. The metrics route spells the request statuses +// upper case; the request log spells them lower case, as RequestLogStatusValues. +var ( RequestStatusValues = "ACCEPTED, REJECTED" - EventStatusValues = "SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED" + EventStatusValues = ValueList(EventStatusValueList) AttemptStatusValues = "SUCCESSFUL, FAILED" TransformationStatusValues = "" ) diff --git a/pkg/hookdeck/status.go b/pkg/hookdeck/status.go new file mode 100644 index 00000000..b3f2c89c --- /dev/null +++ b/pkg/hookdeck/status.go @@ -0,0 +1,50 @@ +package hookdeck + +import "strings" + +// Status vocabularies of the log collections, as value lists. +// +// These are the `status` enums the OpenAPI document declares for the routes the +// CLI and MCP query, and they are not interchangeable: GET /requests describes +// what happened to a request at the edge, while GET /events and +// GET /requests/{id}/events describe where a delivery is in its lifecycle. The +// two sit one argument apart in the MCP schema - hookdeck_requests action +// "list" takes the first and action "events" the second - so both layers need +// to be able to name which vocabulary they mean. +// +// Spelled as the API spells them: the request log enum is lower case and the +// event enum upper case. Callers match case-insensitively and send the +// canonical spelling, so nobody has to know that. +var ( + // EventStatusValueList is the status enum of GET /events and of + // GET /requests/{id}/events, which shares the /events filter set. + EventStatusValueList = []string{"SCHEDULED", "QUEUED", "HOLD", "SUCCESSFUL", "FAILED", "CANCELLED"} + + // RequestLogStatusValueList is the status enum of GET /requests. The + // metrics route spells the same two values upper case; see + // RequestStatusValues. + RequestLogStatusValueList = []string{"accepted", "rejected"} + + // RequestLogStatusValues renders the request log vocabulary for --help and + // for a tool schema. + RequestLogStatusValues = ValueList(RequestLogStatusValueList) +) + +// CanonicalStatusValue returns the vocabulary's own spelling of value, matched +// without regard to case, and false when the vocabulary does not carry it. +// +// The API is strict about both the vocabulary and the case. Checked live on +// 2026-09-14: GET /requests answers 422 "status must be one of [accepted, +// rejected]" for ACCEPTED, and GET /requests/{id}/events answers 422 "status +// must be one of [SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED]" for +// successful. Canonicalising makes either spelling work; the false return lets +// a caller turn a 422 that only ever names one route's enum into a message that +// names the route which does take the value. +func CanonicalStatusValue(vocabulary []string, value string) (string, bool) { + for _, v := range vocabulary { + if strings.EqualFold(v, value) { + return v, true + } + } + return "", false +} diff --git a/pkg/hookdeck/status_test.go b/pkg/hookdeck/status_test.go new file mode 100644 index 00000000..baa57910 --- /dev/null +++ b/pkg/hookdeck/status_test.go @@ -0,0 +1,60 @@ +package hookdeck + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestStatusVocabulariesMatchTheAPI pins the two enums against the OpenAPI +// document at https://api.hookdeck.com/2026-09-01/openapi. +// +// Expectations are hardcoded, so an API-side change will NOT fail this test; +// it catches the vocabularies drifting apart in here. They are easy to mix up +// because the same word means different things one route over. +func TestStatusVocabulariesMatchTheAPI(t *testing.T) { + assert.Equal(t, + []string{"SCHEDULED", "QUEUED", "HOLD", "SUCCESSFUL", "FAILED", "CANCELLED"}, + EventStatusValueList, + "the status enum of GET /events and GET /requests/{id}/events") + assert.Equal(t, + []string{"accepted", "rejected"}, + RequestLogStatusValueList, + "the status enum of GET /requests, which spells them lower case") +} + +// TestEventStatusValuesRenderTheList stops the advertised vocabulary and the +// accepted one drifting: --help and the MCP schema read EventStatusValues, +// while the guards check EventStatusValueList. +func TestEventStatusValuesRenderTheList(t *testing.T) { + assert.Equal(t, ValueList(EventStatusValueList), EventStatusValues) + assert.Equal(t, ValueList(RequestLogStatusValueList), RequestLogStatusValues) +} + +// TestCanonicalStatusValue covers the matching rule. Case is forgiven because +// the two routes disagree about it, but the canonical spelling is what gets +// sent: the API is case-sensitive and 422s "successful" against the upper-case +// enum. +func TestCanonicalStatusValue(t *testing.T) { + tests := []struct { + name string + vocabulary []string + value string + want string + ok bool + }{ + {"exact", EventStatusValueList, "SUCCESSFUL", "SUCCESSFUL", true}, + {"lower case is canonicalised", EventStatusValueList, "successful", "SUCCESSFUL", true}, + {"mixed case is canonicalised", RequestLogStatusValueList, "Accepted", "accepted", true}, + {"other vocabulary is refused", EventStatusValueList, "accepted", "", false}, + {"unknown is refused", RequestLogStatusValueList, "bogus", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := CanonicalStatusValue(tt.vocabulary, tt.value) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} From e7c86910b97e174e248fe890bdbf2c6aa7777e86 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 23:22:46 +0100 Subject: [PATCH 14/16] test: pin the fixes a revert audit could undo in silence A revert audit of #392 found fixes that can be deleted with the whole suite green. The code is correct today; the problem is that nothing would notice if a refactor undid it, and several of these guard the silent-wrong-answer class the release exists to fix. Each test below was written first, then checked by reverting the fix it covers and confirming it fails. - MCP: gate the filters each events route ignores (queue depth, pending and by-issue). Only requests/attempts/transformations were covered, so the delivery_group family of bugs was unprotected on the tool side. - MCP: pin queue_depth -> max_depth at the request, mirroring the CLI's TestQueueDepthMeasureIsTranslatedOnTheWire. Without it the tool sends a 422 for a measure its own schema advertises. - MCP: gate the by-issue route's dimensions, the one events route missing from the dimension table. - `destination create`: extract buildCreateRequest, in the style of buildUpdateRequest and buildUpsertRequest, so both cliPathFromFlags call sites are covered through the command's own wiring. Testing the helper alone could not tell whether the command still called it, and raw dc.cliPath reinstates --config's path being overwritten with "/". - CLI: gate the dimensions of `metrics attempts`, `metrics requests` and `metrics transformations`, driven through each command's RunE. - Delete the default events route's filter guard in both layers: it can never fire, because DefaultEventRouteFilters differs from the union only by issue_id and any issue_id selects the by-issue route above it. A new hookdeck test pins that invariant, so a filter the route does not honour brings the guard back rather than passing unnoticed. - listen: cover Proxy.Run's give-up path end to end, asserting the renderer is told why before it is torn down (#399). The retry budget and its backoff become vars so the test runs in milliseconds rather than twenty seconds; the CLI never changes them. - listen: give InteractiveRenderer an injectable message sink and its first unit tests, covering a session-level OnError surfacing as a ConnectionFailedMsg the model acts on. Two tests that failed for the wrong reason: - TestSetColorEnabledKeepsTheWords asserted on the status bar, so a #399 regression failed as a #404 colour bug. It now asserts on the frame; position belongs to TestStatusBarAlwaysReportsConnectionState. - The #399 acceptance test passed with the status bar removed, because renderConnectingStatus writes the same words into the viewport body. It now matches the status bar line specifically. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- pkg/cmd/destination_cli_type_test.go | 48 ++++++ pkg/cmd/destination_create.go | 24 ++- pkg/cmd/metrics_dimensions_test.go | 135 ++++++++++++++++ pkg/cmd/metrics_events.go | 8 +- pkg/gateway/mcp/tool_metrics.go | 9 +- .../mcp/tool_metrics_dimensions_test.go | 20 ++- pkg/gateway/mcp/tool_metrics_filters_test.go | 141 ++++++++++++++++ pkg/gateway/mcp/tool_metrics_measures_test.go | 28 ++++ pkg/hookdeck/metrics_test.go | 26 +++ pkg/listen/proxy/proxy.go | 12 +- .../proxy/proxy_connection_failed_test.go | 152 ++++++++++++++++++ pkg/listen/proxy/renderer_interactive.go | 76 ++++----- pkg/listen/proxy/renderer_interactive_test.go | 81 ++++++++++ pkg/listen/tui/styles_test.go | 6 +- test/acceptance/listen_tty_test.go | 12 +- 15 files changed, 721 insertions(+), 57 deletions(-) create mode 100644 pkg/listen/proxy/proxy_connection_failed_test.go create mode 100644 pkg/listen/proxy/renderer_interactive_test.go diff --git a/pkg/cmd/destination_cli_type_test.go b/pkg/cmd/destination_cli_type_test.go index 6bc97f2a..a1147adf 100644 --- a/pkg/cmd/destination_cli_type_test.go +++ b/pkg/cmd/destination_cli_type_test.go @@ -174,3 +174,51 @@ func TestConnectionDestinationRejectsDeliveryPolicyForCLI(t *testing.T) { assert.Equal(t, 100, policy["rate"]) }) } + +// runCreateFlags parses args through the real `destination create` flag set and +// returns the request body the command would POST. +func runCreateFlags(t *testing.T, args ...string) (*hookdeck.DestinationCreateRequest, error) { + t.Helper() + dc := newDestinationCreateCmd() + require.NoError(t, dc.cmd.ParseFlags(args)) + return dc.buildCreateRequest(dc.cmd) +} + +// TestCreateRequestHonoursTheCLIPathFlagState covers the two call sites in +// `destination create` rather than cliPathFromFlags on its own. The helper was +// pinned by TestCLIPathFromFlags, but nothing checked that the command still +// called it: replacing either call with the raw flag value reinstates the +// original bug with the suite green. +func TestCreateRequestHonoursTheCLIPathFlagState(t *testing.T) { + t.Run("--config path survives an unset --cli-path", func(t *testing.T) { + req, err := runCreateFlags(t, + "--name", "local-cli", "--type", "CLI", "--config", `{"path":"/webhooks"}`) + require.NoError(t, err) + assert.Equal(t, "/webhooks", req.Config["path"], + "the \"/\" default of an unset --cli-path must not overwrite --config") + }) + + t.Run("an unset --cli-path is not a CLI flag on an HTTP create", func(t *testing.T) { + req, err := runCreateFlags(t, + "--name", "my-api", "--type", "HTTP", "--url", "https://api.example.com/webhooks") + require.NoError(t, err, + "an unset --cli-path must not read as a CLI flag given for an HTTP destination") + assert.Equal(t, "https://api.example.com/webhooks", req.Config["url"]) + assert.NotContains(t, req.Config, "path") + }) + + t.Run("an explicit --cli-path still wins over --config", func(t *testing.T) { + req, err := runCreateFlags(t, + "--name", "local-cli", "--type", "CLI", + "--cli-path", "/from-flag", "--config", `{"path":"/from-config"}`) + require.NoError(t, err) + assert.Equal(t, "/from-flag", req.Config["path"]) + }) + + t.Run("a CLI create with no path at all keeps the default", func(t *testing.T) { + req, err := runCreateFlags(t, "--name", "local-cli", "--type", "CLI") + require.NoError(t, err) + assert.Equal(t, "/", req.Config["path"], + "create still supplies the \"/\" default when nothing named a path") + }) +} diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index c90897ab..d0028a3f 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -99,10 +99,13 @@ func (dc *destinationCreateCmd) validateFlags(cmd *cobra.Command, args []string) return dc.destinationConfigFlags.validateDeliveryPolicyFlags("") } -func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args []string) error { - client := Config.GetAPIClient() - ctx := context.Background() - +// buildCreateRequest assembles the POST body, in the style of +// buildUpdateRequest and buildUpsertRequest. It is split out of the command so +// the flag handling can be tested through the wiring the command actually uses: +// --cli-path's "/" default has to reach both call sites through +// cliPathFromFlags, and testing that helper on its own could not tell whether +// the command still called it. +func (dc *destinationCreateCmd) buildCreateRequest(cmd *cobra.Command) (*hookdeck.DestinationCreateRequest, error) { // Sync url/cliPath into flags for buildDestinationConfigFromIndividualFlags // when not using --config. --cli-path carries a "/" default on create, so it // goes through cliPathFromFlags: an unset flag must not read as a path the @@ -112,7 +115,7 @@ func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) if err != nil { - return err + return nil, err } // For HTTP/CLI, ensure url/path in config when using individual flags @@ -137,6 +140,17 @@ func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args if len(config) > 0 { req.Config = config } + return req, nil +} + +func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + req, err := dc.buildCreateRequest(cmd) + if err != nil { + return err + } dst, err := client.CreateDestination(ctx, req) if err != nil { diff --git a/pkg/cmd/metrics_dimensions_test.go b/pkg/cmd/metrics_dimensions_test.go index 697f8d1e..7510f793 100644 --- a/pkg/cmd/metrics_dimensions_test.go +++ b/pkg/cmd/metrics_dimensions_test.go @@ -2,11 +2,16 @@ package cmd import ( "context" + "net/http" + "net/http/httptest" "testing" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" ) @@ -89,3 +94,133 @@ func TestMetricsHelpListsTheRealDimensions(t *testing.T) { // the API's own spelling and is mapped on the way in. assert.Contains(t, long, "connection_id") } + +// metricsStub serves any metrics endpoint and records the path it was asked +// for, so a dimension the endpoint does not define can be caught reaching it. +func metricsStub(t *testing.T, path *string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(server.Close) + return server +} + +// metricsSubcommand is a metrics command and its RunE, so a test can drive the +// command's own wiring rather than the shared helper it calls. +type metricsSubcommand struct { + cmd *cobra.Command + runE func(*cobra.Command, []string) error +} + +// runMetricsSubcommandAgainst parses the flags as the user typed them and runs +// the command against the stub, so the gate is exercised where the command +// wires it up. +func runMetricsSubcommandAgainst(t *testing.T, server *httptest.Server, sub metricsSubcommand, args ...string) error { + t.Helper() + old := Config + t.Cleanup(func() { Config = old }) + config.ResetAPIClientForTesting() + t.Cleanup(config.ResetAPIClientForTesting) + + Config = config.Config{} + Config.APIBaseURL = server.URL + Config.Profile.APIKey = "sk_test_123456789012" + Config.Profile.ProjectId = "proj_1" + + require.NoError(t, sub.cmd.ParseFlags(append([]string{ + "--start", "2025-01-01T00:00:00Z", + "--end", "2025-01-02T00:00:00Z", + "--measures", "count", + }, args...))) + return sub.runE(sub.cmd, nil) +} + +func attemptsSubcommand() metricsSubcommand { + c := newMetricsAttemptsCmd() + return metricsSubcommand{cmd: c.cmd, runE: c.runE} +} + +func requestsSubcommand() metricsSubcommand { + c := newMetricsRequestsCmd() + return metricsSubcommand{cmd: c.cmd, runE: c.runE} +} + +func transformationsSubcommand() metricsSubcommand { + c := newMetricsTransformationsCmd() + return metricsSubcommand{cmd: c.cmd, runE: c.runE} +} + +// TestSiblingMetricsCommandsGateTheirDimensions covers the three non-events +// metrics commands. Their dimension vocabularies differ sharply - attempts has +// no source_id, requests has no destination_id, transformations has no status - +// so a dimension borrowed from a sibling endpoint is an API 422 for something +// --help appears to offer. Only the events routes were pinned. +func TestSiblingMetricsCommandsGateTheirDimensions(t *testing.T) { + tests := []struct { + name string + sub func() metricsSubcommand + dimension string + contains []string + }{ + { + name: "attempts do not group by source", + sub: attemptsSubcommand, + dimension: "source_id", + contains: []string{"--dimensions", "source_id", "attempt metrics", "destination_id"}, + }, + { + name: "requests do not group by destination", + sub: requestsSubcommand, + dimension: "destination_id", + contains: []string{"--dimensions", "destination_id", "request metrics", "source_id"}, + }, + { + name: "transformations do not group by status", + sub: transformationsSubcommand, + dimension: "status", + contains: []string{"--dimensions", "status", "transformation metrics", "transformation_id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var path string + server := metricsStub(t, &path) + + err := runMetricsSubcommandAgainst(t, server, tt.sub(), "--dimensions", tt.dimension) + require.Error(t, err, "a dimension the endpoint does not define must not reach the API") + for _, want := range tt.contains { + assert.Contains(t, err.Error(), want) + } + assert.Empty(t, path, "no request should have been sent") + }) + } +} + +// TestSiblingMetricsCommandsKeepTheirOwnDimensions is the other half: each +// command's own dimensions must still reach its endpoint. +func TestSiblingMetricsCommandsKeepTheirOwnDimensions(t *testing.T) { + tests := []struct { + name string + sub func() metricsSubcommand + dimension string + path string + }{ + {"attempts group by destination", attemptsSubcommand, "destination_id", "/metrics/attempts"}, + {"requests group by source", requestsSubcommand, "source_id", "/metrics/requests"}, + {"transformations group by log level", transformationsSubcommand, "log_level", "/metrics/transformations"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var path string + server := metricsStub(t, &path) + + require.NoError(t, runMetricsSubcommandAgainst(t, server, tt.sub(), "--dimensions", tt.dimension)) + assert.Contains(t, path, tt.path) + }) + } +} diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index 4058c6e3..d0d49d0e 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -125,9 +125,11 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, return client.QueryEventsByIssue(ctx, params) } // 4. Default → QueryEventMetrics - if err := rejectUnsupportedFilters(params, hookdeck.DefaultEventRouteFilters, "event metrics"); err != nil { - return nil, err - } + // No filter gate here: the default route honours every filter --help offers + // except --issue-id, and a set --issue-id selects the by-issue route above, + // so nothing reaches this fallback for a gate to catch. The invariant is + // pinned by hookdeck.TestDefaultEventRouteHonoursEveryFilterExceptIssueID, + // which fails if a filter the route drops is ever added. if err := rejectUnsupportedDimensions(params, hookdeck.DefaultEventRouteDimensions, "event metrics"); err != nil { return nil, err } diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index d837097d..a6b1440d 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -162,9 +162,12 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp } result, err = client.QueryEventsByIssue(ctx, params) default: - if err := rejectFilters(params, hookdeck.DefaultEventRouteFilters, "event metrics"); err != nil { - return ErrorResult(err.Error()), nil - } + // No filter gate here: the default route honours every filter the tool + // advertises except issue_id, and a set issue_id selects the by-issue + // route above, so nothing reaches this branch for a gate to catch. The + // invariant is pinned by + // hookdeck.TestDefaultEventRouteHonoursEveryFilterExceptIssueID, which + // fails if a filter the route drops is ever added. if err := rejectDimensions(params, hookdeck.DefaultEventRouteDimensions, "event metrics"); err != nil { return ErrorResult(err.Error()), nil } diff --git a/pkg/gateway/mcp/tool_metrics_dimensions_test.go b/pkg/gateway/mcp/tool_metrics_dimensions_test.go index c7ea912e..ad05e059 100644 --- a/pkg/gateway/mcp/tool_metrics_dimensions_test.go +++ b/pkg/gateway/mcp/tool_metrics_dimensions_test.go @@ -23,7 +23,10 @@ func TestMetricsToolRejectsDimensionsTheRouteIgnores(t *testing.T) { action string measures []any dimension string - contains []string + // extra carries the arguments a route needs to be selected at all - + // the by-issue route is chosen by issue_id, not by a measure. + extra map[string]any + contains []string }{ // 422 dimensions[0] must be [destination_id] { @@ -41,6 +44,12 @@ func TestMetricsToolRejectsDimensionsTheRouteIgnores(t *testing.T) { measures: []any{"count"}, dimension: "rejection_cause", contains: []string{"rejection_cause", "event metrics"}, }, + { + name: "per-issue route has a narrower set", action: "events", + measures: []any{"count"}, dimension: "status", + extra: map[string]any{"issue_id": "iss_1"}, + contains: []string{"status", "per-issue event metrics", "issue_id, source_id, destination_id, connection_id"}, + }, { name: "requests do not group by destination", action: "requests", measures: []any{"count"}, dimension: "destination_id", @@ -73,13 +82,18 @@ func TestMetricsToolRejectsDimensionsTheRouteIgnores(t *testing.T) { hookdeck.APIPathPrefix + "/metrics/transformations": fail, }) - result := callTool(t, session, "hookdeck_metrics", map[string]any{ + args := map[string]any{ "action": tt.action, "start": "2025-01-01T00:00:00Z", "end": "2025-01-02T00:00:00Z", "measures": tt.measures, "dimensions": []any{tt.dimension}, - }) + } + for k, v := range tt.extra { + args[k] = v + } + + result := callTool(t, session, "hookdeck_metrics", args) assert.True(t, result.IsError, "%s grouped by %s must be refused", tt.action, tt.dimension) body := textContent(t, result) diff --git a/pkg/gateway/mcp/tool_metrics_filters_test.go b/pkg/gateway/mcp/tool_metrics_filters_test.go index 21245e82..34415422 100644 --- a/pkg/gateway/mcp/tool_metrics_filters_test.go +++ b/pkg/gateway/mcp/tool_metrics_filters_test.go @@ -102,3 +102,144 @@ func TestMetricsToolMapsConnectionDimension(t *testing.T) { assert.False(t, result.IsError) assert.Equal(t, []string{"webhook_id"}, sawDimensions) } + +// TestMetricsToolRejectsFiltersTheEventsRouteIgnores is the events half of the +// gate above, and the one that matters most: `action: events` fans out over +// four endpoints that each honour a different set of filters, so the filter the +// caller passed is dropped by the API and unfiltered totals come back looking +// like an answer. That is the same silent-wrong-answer family as the +// delivery_group bug this release exists for. +// +// Each case names a filter the selected route does not declare, without naming +// a second route: the cross-route guard would otherwise refuse the call first +// and this gate would never be reached. +func TestMetricsToolRejectsFiltersTheEventsRouteIgnores(t *testing.T) { + tests := []struct { + name string + measures []any + filter string + extra map[string]any + contains []string + }{ + { + name: "queue depth does not filter by source", measures: []any{"queue_depth"}, + filter: "source_id", contains: []string{"source_id", "queue depth metrics"}, + }, + { + name: "queue depth does not filter by status", measures: []any{"queue_depth"}, + filter: "status", contains: []string{"status", "queue depth metrics"}, + }, + { + name: "queue depth does not filter by connection", measures: []any{"max_age"}, + filter: "connection_id", contains: []string{"connection_id", "queue depth metrics"}, + }, + { + name: "pending filters by destination only", measures: []any{"pending"}, + filter: "source_id", contains: []string{"source_id", "pending event metrics"}, + }, + { + name: "pending does not filter by delivery group", measures: []any{"pending"}, + filter: "delivery_group", contains: []string{"delivery_group", "pending event metrics"}, + }, + { + name: "per-issue does not filter by status", measures: []any{"count"}, + filter: "status", extra: map[string]any{"issue_id": "iss_1"}, + contains: []string{"status", "per-issue event metrics"}, + }, + { + name: "per-issue does not filter by delivery group", measures: []any{"count"}, + filter: "delivery_group", extra: map[string]any{"issue_id": "iss_1"}, + contains: []string{"delivery_group", "per-issue event metrics"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fail := func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with a filter it would ignore (%s)", r.URL.Path, tt.filter) + } + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events": fail, + hookdeck.APIPathPrefix + "/metrics/events-by-issue": fail, + hookdeck.APIPathPrefix + "/metrics/events-pending-timeseries": fail, + hookdeck.APIPathPrefix + "/metrics/queue-depth": fail, + }) + + args := map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": tt.measures, + tt.filter: "x_bogus", + } + for k, v := range tt.extra { + args[k] = v + } + + result := callTool(t, session, "hookdeck_metrics", args) + + assert.True(t, result.IsError, "events with %s must be refused", tt.filter) + body := textContent(t, result) + for _, want := range tt.contains { + assert.Contains(t, body, want) + } + assert.Contains(t, body, "unfiltered", "the message should say why it matters") + }) + } +} + +// TestMetricsToolKeepsFiltersTheEventsRouteHonours is the other half: the +// filters each route does declare must still reach it, so the gate above cannot +// be satisfied by refusing everything. +func TestMetricsToolKeepsFiltersTheEventsRouteHonours(t *testing.T) { + tests := []struct { + name string + measures []any + args map[string]any + endpoint string + param string + want string + }{ + { + name: "queue depth filters by destination", measures: []any{"queue_depth"}, + args: map[string]any{"destination_id": "des_1"}, + endpoint: "/metrics/queue-depth", param: "filters[destination_id]", want: "des_1", + }, + { + name: "pending filters by destination", measures: []any{"pending"}, + args: map[string]any{"destination_id": "des_1"}, + endpoint: "/metrics/events-pending-timeseries", param: "filters[destination_id]", want: "des_1", + }, + { + name: "per-issue filters by source", measures: []any{"count"}, + args: map[string]any{"issue_id": "iss_1", "source_id": "src_1"}, + endpoint: "/metrics/events-by-issue", param: "filters[source_id]", want: "src_1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + tt.endpoint: func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Query().Get(tt.param) + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + }, + }) + + args := map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": tt.measures, + } + for k, v := range tt.args { + args[k] = v + } + + result := callTool(t, session, "hookdeck_metrics", args) + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/gateway/mcp/tool_metrics_measures_test.go b/pkg/gateway/mcp/tool_metrics_measures_test.go index 2c59e3b8..e5550156 100644 --- a/pkg/gateway/mcp/tool_metrics_measures_test.go +++ b/pkg/gateway/mcp/tool_metrics_measures_test.go @@ -180,3 +180,31 @@ func TestMetricsToolStillAnswersSingleRouteEventQueries(t *testing.T) { assert.False(t, result.IsError, textContent(t, result)) assert.True(t, called, "a per-issue count must still reach the by-issue endpoint") } + +// TestMetricsToolTranslatesQueueDepthMeasureOnTheWire is the MCP mirror of +// TestQueueDepthMeasureIsTranslatedOnTheWire. +// +// "queue_depth" is our own spelling for the route and the tool schema +// advertises it, but /metrics/queue-depth accepts max_depth and max_age only. +// Without the translation the tool sends a 422 for a value it told the caller +// to pass, so this pins the rewrite at the request, not in the helper. +func TestMetricsToolTranslatesQueueDepthMeasureOnTheWire(t *testing.T) { + var sawMeasures []string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/queue-depth": func(w http.ResponseWriter, r *http.Request) { + sawMeasures = r.URL.Query()["measures[]"] + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"queue_depth"}, + }) + + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, []string{"max_depth"}, sawMeasures, + "queue_depth must reach the API as max_depth") +} diff --git a/pkg/hookdeck/metrics_test.go b/pkg/hookdeck/metrics_test.go index 7e9dd72d..d47d95f3 100644 --- a/pkg/hookdeck/metrics_test.go +++ b/pkg/hookdeck/metrics_test.go @@ -4,6 +4,7 @@ import ( "net/url" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,3 +19,28 @@ func TestBuildMetricsQueryIncludesDeliveryGroup(t *testing.T) { require.Equal(t, "cus_priority", query.Get("filters[delivery_group]")) require.Equal(t, []string{"delivery_group"}, query["dimensions[]"]) } + +// TestDefaultEventRouteHonoursEveryFilterExceptIssueID pins the invariant that +// makes the default events route need no filter gate of its own. +// +// `metrics events` routes on measures, then the issue_id dimension, then the +// issue filter, and only then falls through to the default route - so a set +// IssueID never reaches that fallback. IssueID is also the only filter +// DefaultEventRouteFilters withholds from the union the callers advertise, +// which leaves nothing for a default-route gate to catch; the gate that used to +// sit there could not fire. +// +// If a new filter is added that /metrics/events does not honour, this test +// fails and says so: the default route then needs its gate back. +func TestDefaultEventRouteHonoursEveryFilterExceptIssueID(t *testing.T) { + want := EventMetricsFilters + want.IssueID = false + assert.Equal(t, want, DefaultEventRouteFilters, + "the default events route must honour every advertised filter except issue_id; "+ + "a filter it drops needs a gate in queryEventMetricsConsolidated and metricsEvents") + + // The union is what --help and the tool schema offer, so a filter missing + // from it is one no caller can pass. + assert.Equal(t, MetricsFilters{SourceID: true, DestinationID: true, ConnectionID: true, Status: true, IssueID: true, DeliveryGroup: true}, + EventMetricsFilters) +} diff --git a/pkg/listen/proxy/proxy.go b/pkg/listen/proxy/proxy.go index 77ca3b2a..3edc7e6a 100644 --- a/pkg/listen/proxy/proxy.go +++ b/pkg/listen/proxy/proxy.go @@ -31,6 +31,15 @@ const ( unhealthyCheckInterval = 5 * time.Second // Check every 5s when server is unhealthy ) +// The retry budget for a session that has never connected, and the fixed delay +// between those first attempts. Vars rather than consts only so a test can +// drive Run to its give-up path in milliseconds instead of twenty seconds; +// nothing in the CLI changes them. +var ( + maxConnectAttempts = 10 + fixedConnectBackoffMS = 2000 +) + // Config provides the configuration of a Proxy type Config struct { // DeviceName is the name of the device sent to Hookdeck to help identify the device @@ -121,7 +130,6 @@ func (p *Proxy) setWebSocketClient(client *websocket.Client) { // - Create a new CLI session // - Create a new websocket connection func (p *Proxy) Run(parentCtx context.Context) error { - const maxConnectAttempts = 10 nAttempts := 0 // Track whether or not we have connected successfully. @@ -299,7 +307,7 @@ func (p *Proxy) Run(parentCtx context.Context) error { if nAttempts <= maxConnectAttempts { // First 10 attempts: use a fixed 2 second delay - sleepDurationMS = 2000 + sleepDurationMS = fixedConnectBackoffMS } else { // After max attempts: exponential backoff, maximum of 10 second intervals attemptsOverMax := float64(nAttempts - maxConnectAttempts) diff --git a/pkg/listen/proxy/proxy_connection_failed_test.go b/pkg/listen/proxy/proxy_connection_failed_test.go new file mode 100644 index 00000000..cc682115 --- /dev/null +++ b/pkg/listen/proxy/proxy_connection_failed_test.go @@ -0,0 +1,152 @@ +package proxy + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +// recordingRenderer records the lifecycle calls in the order they arrive, so a +// test can assert not just that the renderer was told something but when. +type recordingRenderer struct { + mu sync.Mutex + calls []string + failed error + doneCh chan struct{} +} + +func newRecordingRenderer() *recordingRenderer { + return &recordingRenderer{doneCh: make(chan struct{})} +} + +func (r *recordingRenderer) record(name string) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, name) +} + +func (r *recordingRenderer) recorded() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.calls...) +} + +func (r *recordingRenderer) connectionFailure() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.failed +} + +func (r *recordingRenderer) OnConnecting() { r.record("OnConnecting") } +func (r *recordingRenderer) OnConnected() { r.record("OnConnected") } +func (r *recordingRenderer) OnDisconnected() { r.record("OnDisconnected") } +func (r *recordingRenderer) OnError(err error) { + r.record("OnError") +} + +func (r *recordingRenderer) OnConnectionFailed(err error) { + r.mu.Lock() + r.failed = err + r.mu.Unlock() + r.record("OnConnectionFailed") +} + +func (r *recordingRenderer) OnEventPending(string, *websocket.Attempt, time.Time) {} +func (r *recordingRenderer) OnEventComplete(string, *websocket.Attempt, *EventResponse, time.Time) { +} +func (r *recordingRenderer) OnEventError(string, *websocket.Attempt, error, time.Time) {} +func (r *recordingRenderer) OnConnectionWarning(int32, int) {} +func (r *recordingRenderer) OnServerHealthChanged(bool, error) {} +func (r *recordingRenderer) Cleanup() { r.record("Cleanup") } +func (r *recordingRenderer) Done() <-chan struct{} { return r.doneCh } +func (r *recordingRenderer) Err() error { return nil } + +// TestRunReportsGivingUpToTheRendererBeforeTeardown covers the failure half of +// #399 end to end. +// +// When the CLI exhausts its connection attempts it has to say so inside the +// renderer before tearing it down. The interactive renderer otherwise spends +// the whole attempt budget looking live, then vanishes into the shell with the +// alt-screen already gone - so the order matters as much as the call. +func TestRunReportsGivingUpToTheRendererBeforeTeardown(t *testing.T) { + // One attempt, no backoff: the production budget is 10 attempts two seconds + // apart, which is the same code path twenty seconds slower. + restoreAttempts, restoreBackoff := maxConnectAttempts, fixedConnectBackoffMS + maxConnectAttempts, fixedConnectBackoffMS = 1, 1 + t.Cleanup(func() { maxConnectAttempts, fixedConnectBackoffMS = restoreAttempts, restoreBackoff }) + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == hookdeck.APIPathPrefix+"/cli-sessions" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cli_sess_1"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(api.Close) + + // A plain HTTP server never completes the websocket handshake, so every + // connect attempt fails immediately and for a reportable reason. + ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + t.Cleanup(ws.Close) + + apiURL, err := url.Parse(api.URL) + require.NoError(t, err) + targetURL, err := url.Parse("http://127.0.0.1:1") + require.NoError(t, err) + + renderer := newRecordingRenderer() + p := New(&Config{ + DeviceName: "test-device", + Key: "sk_test_123456789012", + URL: targetURL, + APIBaseURL: api.URL, + WSBaseURL: "ws://" + strings.TrimPrefix(ws.URL, "http://"), + NoWSS: true, + NoHealthcheck: true, + APIClient: &hookdeck.Client{BaseURL: apiURL, APIKey: "sk_test_123456789012"}, + }, nil, renderer) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + runErr := p.Run(ctx) + require.Error(t, runErr, "giving up must be reported as a command failure") + assert.Contains(t, runErr.Error(), "Could not connect") + + calls := renderer.recorded() + failedAt := indexOfCall(calls, "OnConnectionFailed") + cleanupAt := indexOfCall(calls, "Cleanup") + require.GreaterOrEqual(t, failedAt, 0, + "the renderer must be told the CLI gave up, not just the shell: %v", calls) + require.GreaterOrEqual(t, cleanupAt, 0, "the renderer must still be torn down: %v", calls) + assert.Less(t, failedAt, cleanupAt, + "the failure has to be shown before the renderer is torn down: %v", calls) + + failure := renderer.connectionFailure() + require.Error(t, failure, "the renderer needs the reason, not just the fact") + assert.Equal(t, runErr.Error(), failure.Error(), + "the renderer must be given the same give-up error the command returns") +} + +func indexOfCall(calls []string, want string) int { + for i, c := range calls { + if c == want { + return i + } + } + return -1 +} diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index 901273df..c6b3dd84 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -29,6 +29,19 @@ type InteractiveRenderer struct { // before doneCh is closed. mu sync.Mutex runErr error + + // sendMsg delivers a message to the TUI. It is teaProgram.Send in the CLI; + // a test substitutes a recorder, which is the only way to observe what the + // renderer tells the model without a terminal to run Bubble Tea in. + sendMsg func(tea.Msg) +} + +// send hands a message to the TUI, if there is one to hand it to. +func (r *InteractiveRenderer) send(msg tea.Msg) { + if r.sendMsg == nil { + return + } + r.sendMsg(msg) } // NewInteractiveRenderer creates a new interactive renderer with Bubble Tea @@ -63,6 +76,7 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { teaProgram: program, teaModel: &model, doneCh: make(chan struct{}), + sendMsg: program.Send, } // Start TUI in background @@ -86,23 +100,17 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { // OnConnecting is called when starting to connect func (r *InteractiveRenderer) OnConnecting() { - if r.teaProgram != nil { - r.teaProgram.Send(tui.ConnectingMsg{}) - } + r.send(tui.ConnectingMsg{}) } // OnConnected is called when websocket connects func (r *InteractiveRenderer) OnConnected() { - if r.teaProgram != nil { - r.teaProgram.Send(tui.ConnectedMsg{}) - } + r.send(tui.ConnectedMsg{}) } // OnDisconnected is called when websocket disconnects func (r *InteractiveRenderer) OnDisconnected() { - if r.teaProgram != nil { - r.teaProgram.Send(tui.DisconnectedMsg{}) - } + r.send(tui.DisconnectedMsg{}) } // OnError is called when an error occurs @@ -119,10 +127,10 @@ const failedStateLinger = 500 * time.Millisecond // OnConnectionFailed shows the failure state in the status bar. func (r *InteractiveRenderer) OnConnectionFailed(err error) { - if r.teaProgram == nil { + if r.sendMsg == nil { return } - r.teaProgram.Send(tui.ConnectionFailedMsg{Err: err}) + r.send(tui.ConnectionFailedMsg{Err: err}) time.Sleep(failedStateLinger) } @@ -160,21 +168,19 @@ func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket eventSuccess := response.StatusCode >= 200 && response.StatusCode < 300 // Send update message to TUI (will update existing pending event or create new if not found) - if r.teaProgram != nil { - r.teaProgram.Send(tui.UpdateEventMsg{ - EventID: eventID, - AttemptID: attempt.Body.AttemptId, - Time: startTime, - Data: attempt, - Status: eventStatus, - Success: eventSuccess, - LogLine: outputStr, - ResponseStatus: eventStatus, - ResponseHeaders: response.Headers, - ResponseBody: response.Body, - ResponseDuration: response.Duration, - }) - } + r.send(tui.UpdateEventMsg{ + EventID: eventID, + AttemptID: attempt.Body.AttemptId, + Time: startTime, + Data: attempt, + Status: eventStatus, + Success: eventSuccess, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: response.Headers, + ResponseBody: response.Body, + ResponseDuration: response.Duration, + }) } // showPendingEvent shows a pending event (waiting for response) @@ -202,9 +208,7 @@ func (r *InteractiveRenderer) showPendingEvent(eventID string, attempt *websocke ResponseDuration: 0, } - if r.teaProgram != nil { - r.teaProgram.Send(tui.NewEventMsg{Event: event}) - } + r.send(tui.NewEventMsg{Event: event}) } // OnEventError is called when an event encounters an error @@ -231,9 +235,7 @@ func (r *InteractiveRenderer) OnEventError(eventID string, attempt *websocket.At ResponseDuration: 0, } - if r.teaProgram != nil { - r.teaProgram.Send(tui.NewEventMsg{Event: event}) - } + r.send(tui.NewEventMsg{Event: event}) } // OnConnectionWarning is called when approaching connection limits @@ -249,12 +251,10 @@ func (r *InteractiveRenderer) OnConnectionWarning(activeRequests int32, maxConns // OnServerHealthChanged is called when server health status changes func (r *InteractiveRenderer) OnServerHealthChanged(healthy bool, err error) { - if r.teaProgram != nil { - r.teaProgram.Send(tui.ServerHealthMsg{ - Healthy: healthy, - Error: err, - }) - } + r.send(tui.ServerHealthMsg{ + Healthy: healthy, + Error: err, + }) } // Cleanup gracefully stops the TUI and restores terminal diff --git a/pkg/listen/proxy/renderer_interactive_test.go b/pkg/listen/proxy/renderer_interactive_test.go new file mode 100644 index 00000000..7a068d13 --- /dev/null +++ b/pkg/listen/proxy/renderer_interactive_test.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/listen/tui" +) + +// recordingRenderer aside, InteractiveRenderer had no unit tests: its messages +// only ever went to a real tea.Program, which needs a terminal. sendMsg exists +// so they can be recorded instead. +func recordingInteractiveRenderer() (*InteractiveRenderer, *[]tea.Msg) { + var sent []tea.Msg + r := &InteractiveRenderer{doneCh: make(chan struct{})} + r.sendMsg = func(msg tea.Msg) { sent = append(sent, msg) } + return r, &sent +} + +// TestInteractiveRendererReportsASessionErrorAsAConnectionFailure covers the +// other half of #399's failure path. +// +// A session-level OnError means there is no connection at all - a rejected +// session, a dead API - and the TUI has to say so rather than sit on +// "Connecting…" until it is torn down. Per-event errors go to OnEventError and +// are unaffected. +func TestInteractiveRendererReportsASessionErrorAsAConnectionFailure(t *testing.T) { + r, sent := recordingInteractiveRenderer() + + sessionErr := errors.New("error while authenticating with Hookdeck: 401 Unauthorized") + r.OnError(sessionErr) + + require.Len(t, *sent, 1, "a session error must reach the TUI") + failed, ok := (*sent)[0].(tui.ConnectionFailedMsg) + require.True(t, ok, "a session error must be shown as a connection failure, got %T", (*sent)[0]) + assert.Equal(t, sessionErr, failed.Err, "the TUI needs the reason to display") +} + +// TestInteractiveRendererConnectionFailedReachesTheModel checks the message the +// renderer sends is one the model acts on, so the two halves cannot drift: a +// renamed or unhandled message would leave the status bar claiming the CLI is +// still connecting while it exits. +func TestInteractiveRendererConnectionFailedReachesTheModel(t *testing.T) { + r, sent := recordingInteractiveRenderer() + r.OnConnectionFailed(errors.New("dial tcp 127.0.0.1:9: connect: connection refused")) + require.Len(t, *sent, 1) + + model := tui.NewModel(&tui.Config{DeviceName: "test-device"}) + updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m, ok := updated.(tui.Model) + require.True(t, ok, "unexpected model type %T", updated) + + assert.NotContains(t, m.View(), "connection refused", + "the failure must come from the message, not from the initial frame") + + updated, _ = m.Update((*sent)[0]) + m, ok = updated.(tui.Model) + require.True(t, ok, "unexpected model type %T", updated) + + assert.Contains(t, m.View(), "connection refused", + "the model must act on the message the renderer sends") +} + +// TestInteractiveRendererWithoutATUISendsNothing keeps the nil-program guard: +// the renderer is constructed before Bubble Tea is known to be usable, and +// these are called from Proxy.Run regardless. +func TestInteractiveRendererWithoutATUISendsNothing(t *testing.T) { + r := &InteractiveRenderer{doneCh: make(chan struct{})} + assert.NotPanics(t, func() { + r.OnConnecting() + r.OnConnected() + r.OnDisconnected() + r.OnError(errors.New("boom")) + r.OnConnectionFailed(errors.New("boom")) + r.OnServerHealthChanged(false, errors.New("boom")) + }) +} diff --git a/pkg/listen/tui/styles_test.go b/pkg/listen/tui/styles_test.go index dc5f040d..21c8c9a7 100644 --- a/pkg/listen/tui/styles_test.go +++ b/pkg/listen/tui/styles_test.go @@ -107,5 +107,9 @@ func TestSetColorEnabledKeepsTheWords(t *testing.T) { view := m.View() assert.Contains(t, view, "Listening on 1 source • 1 connection") - assert.Contains(t, lastLine(view), connectingLabel) + // Anywhere in the frame, not specifically the status bar: where the + // connection state is drawn belongs to TestStatusBarAlwaysReportsConnection- + // State. Asserting on lastLine here meant a #399 status-bar regression + // failed as a #404 colour bug and pointed at the wrong fix. + assert.Contains(t, view, connectingLabel) } diff --git a/test/acceptance/listen_tty_test.go b/test/acceptance/listen_tty_test.go index ac2bbde9..8d478ba3 100644 --- a/test/acceptance/listen_tty_test.go +++ b/test/acceptance/listen_tty_test.go @@ -121,8 +121,16 @@ func TestListenInteractiveShowsPendingStateBeforeItIsConnected(t *testing.T) { require.Contains(t, out, "Listening on", "the TUI should have rendered; without that this test proves nothing") - assert.Contains(t, out, "Connecting…", - "an unconnected session must say so, not render a live-looking layout (#399)") + // Assert on the status bar specifically. renderConnectingStatus writes the + // same words into the viewport body, so a plain substring check passed even + // with the status bar removed entirely - the weaker surface satisfied the + // acceptance assertion for #399, which is about the bar always being drawn. + // With no events selected the bar is "● Connecting… • [q] Quit", and only + // the bar carries that tail. + plain := ttySGRPattern.ReplaceAllString(out, "") + assert.Regexp(t, regexp.MustCompile("Connecting…[^\\r\\n]*\\[q\\] Quit"), plain, + "the status bar itself must report the pending state (#399); "+ + "the same words in the viewport body are not the line this test is about") assert.NotContains(t, out, "Connected.", "the CLI never connected, so it must never claim it did") } From faa742f12da47c50e13db57fd4ab456abbb5e6a0 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Mon, 14 Sep 2026 23:54:04 +0100 Subject: [PATCH 15/16] fix: close the cross-branch gaps an adversarial review found Eight findings from a review of the merged PR #392, most of them one layer fixed and the other missed. - RejectUnsupportedDimensions: say that the delivery_group/destination_id rule is the API's and applies on every route offering the dimension, not just events. Verified live against `metrics attempts`. - Report a refused dimension in the caller's spelling. Both callers rewrite connection_id to webhook_id before validating, so the refusal named a token the caller never typed, beside an allowed list that spelled it connection_id. The filter path was already correct. - hookdeck_events action "list" now canonicalises `status` like hookdeck_requests action "events" does. Same collection, same enum, and only one of them accepted "failed". - apiErrorMessage: hold "data" as a raw value. Decoding it straight into a []json.RawMessage failed the whole unmarshal on an object or string data and threw the top-level "message" away with it, which is the raw body dump the function exists to prevent. - The CLI canonicalises --status too: `request list` against the request-log enum, `event list` and `request events` against the event enum, and --help now names the vocabulary each one takes. MCP has done this since status.go landed; the CLI had not, so ACCEPTED worked through one surface and 422'd through the other. - --config/--config-file and the individual destination config flags are now a refused conflict on create, update and upsert alike. They disagreed three ways before, and `upsert --config ... --url ...` without --type dropped the URL and exited 0. - One routing table: hookdeck.RouteForMeasures replaces the CLI's map and MCP's containsAny list, and the route-name constants replace the hand-written strings that gave one route two names in adjacent errors. - REFERENCE.md documented `metrics queue-depth`, `metrics pending` and `metrics events-by-issue`, none of which exist; the per-route dimension gating was undocumented. Both fixed, and a test now pins every `gateway metrics ` in the prose to a real subcommand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- REFERENCE.md | 35 ++-- pkg/cmd/destination_common.go | 58 ++++++- pkg/cmd/destination_config_json_test.go | 153 +++++++++++++++++ pkg/cmd/destination_create.go | 25 +-- pkg/cmd/destination_update.go | 17 +- pkg/cmd/destination_upsert.go | 35 ++-- pkg/cmd/event_list.go | 61 ++++--- pkg/cmd/metrics_events.go | 45 ++--- pkg/cmd/metrics_events_routing_test.go | 35 ++++ pkg/cmd/reference_metrics_doc_test.go | 49 ++++++ pkg/cmd/request_events.go | 13 +- pkg/cmd/request_list.go | 52 +++--- pkg/cmd/status_flag.go | 72 ++++++++ pkg/cmd/status_flag_test.go | 162 ++++++++++++++++++ pkg/gateway/mcp/tool_events.go | 33 +++- pkg/gateway/mcp/tool_metrics.go | 23 ++- .../mcp/tool_requests_events_filters_test.go | 49 ++++++ pkg/hookdeck/client.go | 51 ++++-- pkg/hookdeck/client_error_message_test.go | 29 ++++ pkg/hookdeck/metrics_dimensions_test.go | 78 +++++++++ pkg/hookdeck/metrics_filters.go | 49 +++++- 21 files changed, 967 insertions(+), 157 deletions(-) create mode 100644 pkg/cmd/reference_metrics_doc_test.go create mode 100644 pkg/cmd/status_flag.go create mode 100644 pkg/cmd/status_flag_test.go diff --git a/REFERENCE.md b/REFERENCE.md index ce131dde..f047910a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1089,8 +1089,8 @@ hookdeck gateway destination create [flags] | `--basic-auth-user` | `string` | Username for Basic auth | | `--bearer-token` | `string` | Bearer token for destination auth | | `--cli-path` | `string` | Path for CLI destinations (default "/") | -| `--config` | `string` | JSON object for destination config (overrides individual flags if set) | -| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | +| `--config` | `string` | JSON object for the whole destination config; cannot be combined with the individual config flags | +| `--config-file` | `string` | Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | | `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | @@ -1160,8 +1160,8 @@ hookdeck gateway destination update [flags] | `--basic-auth-user` | `string` | Username for Basic auth | | `--bearer-token` | `string` | Bearer token for destination auth | | `--cli-path` | `string` | Path for CLI destinations | -| `--config` | `string` | JSON object for destination config (overrides individual flags if set) | -| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | +| `--config` | `string` | JSON object for the whole destination config; cannot be combined with the individual config flags | +| `--config-file` | `string` | Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | | `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | @@ -1228,8 +1228,8 @@ hookdeck gateway destination upsert [flags] | `--basic-auth-user` | `string` | Username for Basic auth | | `--bearer-token` | `string` | Bearer token for destination auth | | `--cli-path` | `string` | Path for CLI destinations | -| `--config` | `string` | JSON object for destination config (overrides individual flags if set) | -| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | +| `--config` | `string` | JSON object for the whole destination config; cannot be combined with the individual config flags | +| `--config-file` | `string` | Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | | `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | @@ -1746,7 +1746,7 @@ hookdeck gateway request list [flags] | `--prev` | `string` | Pagination cursor for previous page | | `--rejection-cause` | `string` | Filter by rejection cause | | `--source-id` | `string` | Filter by source ID | -| `--status` | `string` | Filter by status | +| `--status` | `string` | Filter by status (accepted, rejected) | | `--verified` | `string` | Filter by verified (true/false) | **Examples:** @@ -1946,7 +1946,9 @@ hookdeck gateway attempt get atm_abc123 ## Metrics -Query Event Gateway metrics (events, requests, attempts, queue depth, pending events, events by issue, transformations). All metrics commands require `--start` and `--end` (ISO 8601 date-time). +Query Event Gateway metrics. There are four subcommands — `events`, `requests`, `attempts` and `transformations` — and all of them require `--start` and `--end` (ISO 8601 date-time). + +Queue depth, pending events and per-issue breakdowns have no subcommand of their own: `metrics events` answers all three, choosing the endpoint from `--measures` and `--dimensions`. **Use cases and examples:** @@ -1955,9 +1957,9 @@ Query Event Gateway metrics (events, requests, attempts, queue depth, pending ev | Event volume and failure rate over time | `hookdeck gateway metrics events --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --granularity 1d --measures count,failed_count,error_rate` | | Request acceptance vs rejection | `hookdeck gateway metrics requests --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count,accepted_count,rejected_count` | | Delivery latency (attempts) | `hookdeck gateway metrics attempts --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures response_latency_avg,response_latency_p95` | -| Queue backlog per destination | `hookdeck gateway metrics queue-depth --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures max_depth,max_age --destination-id dest_xxx` | -| Pending events over time | `hookdeck gateway metrics pending --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --granularity 1h --measures count` | -| Events grouped by issue (debugging) | `hookdeck gateway metrics events-by-issue iss_xxx --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count` | +| Queue backlog per destination | `hookdeck gateway metrics events --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures max_depth,max_age --destination-id dest_xxx` | +| Pending events over time | `hookdeck gateway metrics events --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --granularity 1h --measures pending` | +| Events grouped by issue (debugging) | `hookdeck gateway metrics events --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count --dimensions issue_id --issue-id iss_xxx` | | Transformation errors | `hookdeck gateway metrics transformations --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count,failed_count,error_rate` | **Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--output` (json). @@ -1977,6 +1979,17 @@ Passing one where it does not apply is an `unknown flag` error rather than a sil `metrics events` routes to a different endpoint depending on `--measures` and `--dimensions`, so some of its filters are rejected for a given query — `--delivery-group` and `--status` cannot be combined with `--measures pending`, for example. The error names the flag and the route. +**`--dimensions` is gated the same way.** Each endpoint defines its own set, and `metrics events` advertises the union, so a dimension the chosen route does not group by is refused by name rather than sent to the API as a 422: + +| Route | Selected by | Groups by | +| --- | --- | --- | +| event metrics (default) | anything else | `source_id`, `destination_id`, `connection_id`, `delivery_group`, `status`, `error_code`, `event_data_id`, `cli_id`, `cli_user_id`, `attempts`, `response_status` | +| queue depth metrics | `--measures queue_depth`, `max_depth` or `max_age` | `destination_id`, `delivery_group` | +| pending event metrics | `--measures pending` | `destination_id` | +| per-issue event metrics | `--dimensions issue_id` or `--issue-id` | `issue_id`, `source_id`, `destination_id`, `connection_id` | + +`metrics requests`, `metrics attempts` and `metrics transformations` each have a single set, listed in their own `--help`. One rule is the API's and applies wherever the dimension is offered, `metrics attempts` included: grouping by `delivery_group` requires a `--destination-id` filter. + Only one endpoint answers a query, so a request cannot ask for two of them at once. `queue_depth`, `max_depth` and `max_age` select queue-depth metrics and `pending` selects pending metrics; neither can be combined with per-issue metrics (`--dimensions issue_id` or `--issue-id`), and measures belonging to two routes cannot be mixed in one `--measures`. Each combination is refused by name rather than answered from whichever route happened to match first. ## Utilities diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index 54181454..cc719c4a 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -13,8 +13,9 @@ import ( ) // destinationConfigFlags holds destination config flags for create/upsert/update. -// Used by destination create, upsert, update. When both --config/--config-file and -// individual flags are set, --config/--config-file take precedence. +// Used by destination create, upsert, update. They are an alternative to +// --config/--config-file, not an overlay on it: each input describes the whole +// config, so naming both is refused by rejectConfigJSONWithIndividualFlags. type destinationConfigFlags struct { URL string CliPath string @@ -297,6 +298,59 @@ var typeSpecificDestinationFlags = []struct { {"cli-path", "CLI", func(f *destinationConfigFlags) bool { return f.CliPath != "" }}, } +// destinationIndividualConfigFlags are the flags that set a field inside the +// destination config, which is exactly what --config and --config-file supply +// wholesale. +var destinationIndividualConfigFlags = []string{ + "url", "cli-path", "http-method", "path-forwarding-disabled", + "auth-method", "bearer-token", "basic-auth-user", "basic-auth-pass", + "api-key", "api-key-header", "api-key-to", + "custom-signature-secret", "custom-signature-key", + "rate-limit", "rate-limit-period", + "delivery-group-key", "delivery-group-rate", "delivery-group-rate-period", + "delivery-group-overrides", +} + +// rejectConfigJSONWithIndividualFlags refuses --config or --config-file next to +// a flag that sets one of the same fields. +// +// The two ways of describing a config disagreed with each other and the three +// commands disagreed about how. --config was documented as winning, and did on +// `update`; `create` and `upsert` then overlaid --url and --cli-path back on +// top of it, but `upsert` only reached that overlay when --type was passed, +// because resolveDestinationType returns early on the --config path. So +// `upsert --config '{"url":"https://old"}' --url https://new` exited 0 having +// sent the old URL, while the same flags with --type HTTP sent the new one, and +// `update` sent the old one either way (the #406 shape, in a corner). +// +// Refusing the combination is what fixes all of that at once. The alternative - +// making the individual flag win everywhere - only reaches the two fields the +// overlays happen to cover: --auth-method, --http-method, --rate-limit and the +// delivery-group flags would still be dropped in silence under --config, and +// merging them in raises questions (what happens to delivery_policy.groups?) +// that nobody has asked for. Either input describes the whole config, so asking +// for one is unambiguous and asking for both never was. +func rejectConfigJSONWithIndividualFlags(cmd *cobra.Command, configStr, configFile string) error { + if configStr == "" && configFile == "" { + return nil + } + jsonFlag := "--config" + if configStr == "" { + jsonFlag = "--config-file" + } + for _, name := range destinationIndividualConfigFlags { + flag := cmd.Flags().Lookup(name) + // Changed, not the value: --api-key-to and --cli-path carry defaults, + // and a default the user never typed is not a conflict. + if flag == nil || !flag.Changed { + continue + } + return fmt.Errorf("--%s cannot be combined with %s: %s supplies the whole config, so put the field in the JSON or drop %s", + name, jsonFlag, jsonFlag, jsonFlag) + } + return nil +} + // hasAnyTypeSpecificFlag reports whether a flag was given that only one // destination type has a field for. func (f *destinationConfigFlags) hasAnyTypeSpecificFlag() bool { diff --git a/pkg/cmd/destination_config_json_test.go b/pkg/cmd/destination_config_json_test.go index 1f7e0b6e..15f1308c 100644 --- a/pkg/cmd/destination_config_json_test.go +++ b/pkg/cmd/destination_config_json_test.go @@ -5,8 +5,11 @@ import ( "path/filepath" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" ) // TestBuildDestinationConfigFromJSONString verifies that --config (JSON string) parses @@ -166,3 +169,153 @@ func TestBuildDestinationConfigFromJSONFile(t *testing.T) { assert.Contains(t, err.Error(), "config file") }) } + +// withTestAPIKey gives validateFlags a key to accept, so these cases reach the +// flag checks that follow it. +func withTestAPIKey(t *testing.T) { + t.Helper() + old := Config + t.Cleanup(func() { Config = old }) + Config = config.Config{} + Config.Profile.APIKey = "sk_test_123456789012" +} + +// destinationConfigCommand is one of the three commands that accept both a +// config JSON and the individual config flags, reduced to what these cases +// need: set some flags, run the validation the command runs. +type destinationConfigCommand struct { + name string + cmd *cobra.Command + validate func(*cobra.Command, []string) error + args []string +} + +// destinationConfigCommands returns a fresh instance of each command. pflag +// records "was this flag given" on the flag itself, so a command may only be +// used for one case. +func destinationConfigCommands() []destinationConfigCommand { + create := newDestinationCreateCmd() + update := newDestinationUpdateCmd() + upsert := newDestinationUpsertCmd() + return []destinationConfigCommand{ + {"create", create.cmd, create.validateFlags, nil}, + {"update", update.cmd, update.validateFlags, []string{"des_1"}}, + {"upsert", upsert.cmd, upsert.validateFlags, []string{"my-http"}}, + } +} + +// TestDestinationConfigJSONRefusesIndividualFlags pins the contract the three +// commands could not agree on. +// +// --config was documented as overriding the individual flags and did on +// `update`; `create` and `upsert` overlaid --url back on top of it, and +// `upsert` only reached that overlay when --type was passed, because +// resolveDestinationType returns early on the --config path. So +// `upsert my-http --config '{"url":"https://old"}' --url https://new` exited 0 +// having sent https://old with no mention of --url, and the same flags with +// --type HTTP sent https://new while `update` still sent https://old. Either +// input describes the whole config, so asking for both is now a conflict. +func TestDestinationConfigJSONRefusesIndividualFlags(t *testing.T) { + conflicts := []struct { + flag string + value string + }{ + // The reported shape: silently dropped on the --config path. + {"url", "https://new.example.com/hook"}, + {"cli-path", "/webhooks"}, + {"http-method", "PUT"}, + // Never covered by any overlay, so silently dropped on all three + // commands whatever the type. + {"auth-method", "bearer"}, + {"bearer-token", "tok_123"}, + {"rate-limit", "100"}, + {"delivery-group-key", "body.customer_id"}, + } + + for i, cmdUnderTest := range destinationConfigCommands() { + for _, tt := range conflicts { + t.Run(cmdUnderTest.name+" --config with --"+tt.flag, func(t *testing.T) { + withTestAPIKey(t) + c := destinationConfigCommands()[i] + require.NoError(t, c.cmd.Flags().Set("config", `{"url":"https://old.example.com/hook"}`)) + require.NoError(t, c.cmd.Flags().Set(tt.flag, tt.value)) + + err := c.validate(c.cmd, c.args) + require.Error(t, err, "--%s alongside --config was dropped without a word", tt.flag) + assert.Contains(t, err.Error(), "--"+tt.flag) + assert.Contains(t, err.Error(), "--config") + }) + } + } +} + +// TestDestinationConfigFileRefusesIndividualFlagsToo covers the other JSON +// input. --config-file reaches exactly the same builder, so the two have to +// answer the same way. +func TestDestinationConfigFileRefusesIndividualFlagsToo(t *testing.T) { + for i, c := range destinationConfigCommands() { + t.Run(c.name, func(t *testing.T) { + withTestAPIKey(t) + c := destinationConfigCommands()[i] + require.NoError(t, c.cmd.Flags().Set("config-file", "/some/config.json")) + require.NoError(t, c.cmd.Flags().Set("url", "https://new.example.com/hook")) + + err := c.validate(c.cmd, c.args) + require.Error(t, err) + assert.Contains(t, err.Error(), "--url") + assert.Contains(t, err.Error(), "--config-file") + }) + } +} + +// TestDestinationConfigJSONAloneIsStillAccepted is the other half: the refusal +// must key off flags the user actually typed, not off flags that carry a +// default. --api-key-to defaults to "header" on all three commands and +// --cli-path defaults to "/" on create, and neither is something the caller +// asked for. +func TestDestinationConfigJSONAloneIsStillAccepted(t *testing.T) { + for i, c := range destinationConfigCommands() { + t.Run(c.name, func(t *testing.T) { + withTestAPIKey(t) + c := destinationConfigCommands()[i] + require.NoError(t, c.cmd.Flags().Set("config", `{"url":"https://api.example.com/hooks"}`)) + + assert.NoError(t, c.validate(c.cmd, c.args), + "a config JSON on its own is the whole point of the flag") + }) + } +} + +// TestDestinationUpsertAndUpdateAgreeOnConfigJSON states the invariant +// directly, because the two disagreeing is what made the corner hard to see: +// the same flags have to produce the same answer on both commands, with and +// without --type. +func TestDestinationUpsertAndUpdateAgreeOnConfigJSON(t *testing.T) { + for _, declaredType := range []string{"", "HTTP"} { + name := "without --type" + if declaredType != "" { + name = "with --type " + declaredType + } + t.Run(name, func(t *testing.T) { + withTestAPIKey(t) + + update := newDestinationUpdateCmd() + upsert := newDestinationUpsertCmd() + for _, c := range []*cobra.Command{update.cmd, upsert.cmd} { + require.NoError(t, c.Flags().Set("config", `{"url":"https://old.example.com/hook"}`)) + require.NoError(t, c.Flags().Set("url", "https://new.example.com/hook")) + if declaredType != "" { + require.NoError(t, c.Flags().Set("type", declaredType)) + } + } + + updateErr := update.validateFlags(update.cmd, []string{"des_1"}) + upsertErr := upsert.validateFlags(upsert.cmd, []string{"my-http"}) + + require.Error(t, updateErr) + require.Error(t, upsertErr) + assert.Equal(t, updateErr.Error(), upsertErr.Error(), + "update and upsert must not resolve the same flags differently") + }) + } +} diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index d0028a3f..71415d8b 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -51,8 +51,8 @@ Examples: dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API) (required)") dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations (required for type HTTP)") dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "/", "Path for CLI destinations") - dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") - dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for the whole destination config; cannot be combined with the individual config flags") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags") dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") @@ -85,14 +85,19 @@ func (dc *destinationCreateCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } + // --config / --config-file supply the whole config, so an individual config + // flag alongside one of them is a conflict, not an override. Refused rather + // than silently resolved: the three commands resolved it three different + // ways and one of them dropped --url without a word. + if err := rejectConfigJSONWithIndividualFlags(cmd, dc.config, dc.configFile); err != nil { + return err + } t := strings.ToUpper(dc.destType) if t == "HTTP" && dc.url == "" && dc.config == "" && dc.configFile == "" { return fmt.Errorf("--url is required for HTTP destinations") } - // --config / --config-file take precedence: buildDestinationConfigFromFlags - // returns their JSON and never looks at the individual flags. Validating - // those flags here anyway rejected commands over a value that would have - // been ignored. + // Nothing below applies on the config-JSON path: the individual flags are + // refused above, so there is nothing left for them to validate. if dc.config != "" || dc.configFile != "" { return nil } @@ -118,14 +123,14 @@ func (dc *destinationCreateCmd) buildCreateRequest(cmd *cobra.Command) (*hookdec return nil, err } - // For HTTP/CLI, ensure url/path in config when using individual flags + // --url needs no overlay: the builder above copies it in under type HTTP, + // and an individual flag can no longer arrive beside --config. --cli-path + // still does, for the "/" default that only create supplies - including on + // the --config path, where the JSON may simply not name a path. t := strings.ToUpper(dc.destType) if config == nil { config = make(map[string]interface{}) } - if t == "HTTP" && dc.url != "" { - config["url"] = dc.url - } if t == "CLI" { applyCLIPath(config, cliPathFromFlags(cmd, dc.cliPath), true) } diff --git a/pkg/cmd/destination_update.go b/pkg/cmd/destination_update.go index 4312c54b..5b4b19b5 100644 --- a/pkg/cmd/destination_update.go +++ b/pkg/cmd/destination_update.go @@ -49,8 +49,8 @@ Examples: dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API)") dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations") dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "", "Path for CLI destinations") - dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") - dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for the whole destination config; cannot be combined with the individual config flags") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags") dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") @@ -84,10 +84,15 @@ func (dc *destinationUpdateCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - // --config / --config-file take precedence: buildDestinationConfigFromFlags - // returns their JSON and never looks at the individual flags. Validating - // those flags here anyway rejected commands over a value that would have - // been ignored. + // --config / --config-file supply the whole config, so an individual config + // flag alongside one of them is a conflict, not an override. Refused rather + // than silently resolved: the three commands resolved it three different + // ways and one of them dropped --url without a word. + if err := rejectConfigJSONWithIndividualFlags(cmd, dc.config, dc.configFile); err != nil { + return err + } + // Nothing below applies on the config-JSON path: the individual flags are + // refused above, so there is nothing left for them to validate. if dc.config != "" || dc.configFile != "" { return nil } diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index cb6c9dbf..abacc187 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -49,8 +49,8 @@ Examples: dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API)") dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations") dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "", "Path for CLI destinations") - dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") - dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for the whole destination config; cannot be combined with the individual config flags") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags") dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") @@ -82,10 +82,15 @@ func (dc *destinationUpsertCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - // --config / --config-file take precedence: buildDestinationConfigFromFlags - // returns their JSON and never looks at the individual flags. Validating - // those flags here anyway rejected commands over a value that would have - // been ignored. + // --config / --config-file supply the whole config, so an individual config + // flag alongside one of them is a conflict, not an override. Refused rather + // than silently resolved: the three commands resolved it three different + // ways and one of them dropped --url without a word. + if err := rejectConfigJSONWithIndividualFlags(cmd, dc.config, dc.configFile); err != nil { + return err + } + // Nothing below applies on the config-JSON path: the individual flags are + // refused above, so there is nothing left for them to validate. if dc.config != "" || dc.configFile != "" { return nil } @@ -188,18 +193,14 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * return nil, err } - // Overlay for the --config path, where the config JSON was returned verbatim - // and the individual flag still has to win. + // No overlay here. It existed to put --url and --cli-path back on top of a + // --config body, and only fired when --type was passed, because + // resolveDestinationType returns early on the --config path: that is how + // `upsert --config '{"url":...}' --url ...` exited 0 having sent the old + // URL while the same flags with --type HTTP sent the new one, and `update` + // disagreed with both. The combination is now refused in validateFlags, so + // every config field arrives through the builder above, once. rt := strings.ToUpper(resolvedType) - if config == nil { - config = make(map[string]interface{}) - } - if rt == "HTTP" && dc.url != "" { - config["url"] = dc.url - } - if rt == "CLI" { - applyCLIPath(config, dc.cliPath, false) - } req := &hookdeck.DestinationCreateRequest{ Name: dc.name, diff --git a/pkg/cmd/event_list.go b/pkg/cmd/event_list.go index 9ef942e6..15f8e8b2 100644 --- a/pkg/cmd/event_list.go +++ b/pkg/cmd/event_list.go @@ -15,32 +15,32 @@ import ( type eventListCmd struct { cmd *cobra.Command - id string - connectionID string - sourceID string - destinationID string - status string - attempts string - responseStatus string - errorCode string - cliID string - issueID string - createdAfter string - createdBefore string - successfulAfter string - successfulBefore string + id string + connectionID string + sourceID string + destinationID string + status string + attempts string + responseStatus string + errorCode string + cliID string + issueID string + createdAfter string + createdBefore string + successfulAfter string + successfulBefore string lastAttemptAfter string lastAttemptBefore string - headers string - body string - path string - parsedQuery string - orderBy string - dir string - limit int - next string - prev string - output string + headers string + body string + path string + parsedQuery string + orderBy string + dir string + limit int + next string + prev string + output string deliveryGroup string } @@ -66,7 +66,7 @@ Examples: ec.cmd.Flags().StringVar(&ec.sourceID, "source-id", "", "Filter by source ID") ec.cmd.Flags().StringVar(&ec.destinationID, "destination-id", "", "Filter by destination ID") ec.cmd.Flags().StringVar(&ec.deliveryGroup, "delivery-group", "", "Filter by delivery group") - ec.cmd.Flags().StringVar(&ec.status, "status", "", "Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED)") + ec.cmd.Flags().StringVar(&ec.status, "status", "", eventStatusFlag.usage()) ec.cmd.Flags().StringVar(&ec.attempts, "attempts", "", "Filter by number of attempts (integer or operators)") ec.cmd.Flags().StringVar(&ec.responseStatus, "response-status", "", "Filter by HTTP response status (e.g. 200, 500)") ec.cmd.Flags().StringVar(&ec.errorCode, "error-code", "", "Filter by error code") @@ -97,6 +97,13 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error return err } + // The API is strict about the case of the event enum, so accept either and + // send its own spelling - the same canonicalisation MCP applies. + status, err := eventStatusFlag.canonical(ec.status) + if err != nil { + return err + } + client := Config.GetAPIClient() params := make(map[string]string) if ec.id != "" { @@ -114,8 +121,8 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error if ec.deliveryGroup != "" { params["delivery_group"] = ec.deliveryGroup } - if ec.status != "" { - params["status"] = ec.status + if status != "" { + params["status"] = status } if ec.attempts != "" { params["attempts"] = ec.attempts diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index d0d49d0e..8e6a8482 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -40,23 +40,6 @@ Dimensions: ` + metricsEventsDimensions + `.`), return c } -// queueDepthMeasures are measures that route to the queue-depth API endpoint. -var queueDepthMeasures = map[string]bool{ - "queue_depth": true, - "max_depth": true, - "max_age": true, -} - -// hasMeasure checks whether any of the requested measures match the given set. -func hasMeasure(params hookdeck.MetricsQueryParams, set map[string]bool) bool { - for _, m := range params.Measures { - if set[m] { - return true - } - } - return false -} - // hasDimension checks whether any of the requested dimensions match the given name. func hasDimension(params hookdeck.MetricsQueryParams, name string) bool { for _, d := range params.Dimensions { @@ -79,13 +62,19 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if err := hookdeck.RejectCrossRouteEventQuery(params, "--measures", "--dimensions", hookdeck.CLIFilterNames); err != nil { return nil, err } + // Which measures belong to which endpoint is the shared table's to know, and + // the route names are its constants: a second copy here could disagree with + // the refusal above and dispatch a query it had just accepted to the wrong + // endpoint. + measureRoute := hookdeck.RouteForMeasures(params.Measures) + // Route based on measures/dimensions: - // 1. If measures include queue_depth, max_depth, or max_age → QueryQueueDepth - if hasMeasure(params, queueDepthMeasures) { - if err := rejectUnsupportedFilters(params, hookdeck.QueueDepthRouteFilters, "queue depth metrics"); err != nil { + // 1. Measures naming the queue-depth route → QueryQueueDepth + if measureRoute == hookdeck.EventRouteQueueDepth { + if err := rejectUnsupportedFilters(params, hookdeck.QueueDepthRouteFilters, hookdeck.EventRouteQueueDepth); err != nil { return nil, err } - if err := rejectUnsupportedDimensions(params, hookdeck.QueueDepthRouteDimensions, "queue depth metrics"); err != nil { + if err := rejectUnsupportedDimensions(params, hookdeck.QueueDepthRouteDimensions, hookdeck.EventRouteQueueDepth); err != nil { return nil, err } // The endpoint accepts max_depth and max_age only. "queue_depth" is our own @@ -95,15 +84,15 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, queueParams.Measures = hookdeck.TranslateQueueDepthMeasures(params.Measures) return client.QueryQueueDepth(ctx, queueParams) } - // 2. If measures include "pending" → QueryEventsPendingTimeseries. + // 2. Measures naming the pending route → QueryEventsPendingTimeseries. // API expects measures[]=count; "pending" is only used for routing. // Granularity is optional on this route, so it must not gate the routing: // gating it sent "pending" to the default endpoint, which rejects the measure. - if hasMeasure(params, map[string]bool{"pending": true}) { - if err := rejectUnsupportedFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (--measures pending)"); err != nil { + if measureRoute == hookdeck.EventRoutePending { + if err := rejectUnsupportedFilters(params, hookdeck.PendingTimeseriesRouteFilters, hookdeck.EventRoutePending); err != nil { return nil, err } - if err := rejectUnsupportedDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, "pending event metrics (--measures pending)"); err != nil { + if err := rejectUnsupportedDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, hookdeck.EventRoutePending); err != nil { return nil, err } pendingParams := params @@ -116,10 +105,10 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, if params.IssueID == "" { return nil, errors.New("per-issue metrics require --issue-id (required when using --dimensions issue_id)") } - if err := rejectUnsupportedFilters(params, hookdeck.EventsByIssueRouteFilters, "per-issue event metrics"); err != nil { + if err := rejectUnsupportedFilters(params, hookdeck.EventsByIssueRouteFilters, hookdeck.EventRouteByIssue); err != nil { return nil, err } - if err := rejectUnsupportedDimensions(params, hookdeck.EventsByIssueRouteDimensions, "per-issue event metrics"); err != nil { + if err := rejectUnsupportedDimensions(params, hookdeck.EventsByIssueRouteDimensions, hookdeck.EventRouteByIssue); err != nil { return nil, err } return client.QueryEventsByIssue(ctx, params) @@ -130,7 +119,7 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client, // so nothing reaches this fallback for a gate to catch. The invariant is // pinned by hookdeck.TestDefaultEventRouteHonoursEveryFilterExceptIssueID, // which fails if a filter the route drops is ever added. - if err := rejectUnsupportedDimensions(params, hookdeck.DefaultEventRouteDimensions, "event metrics"); err != nil { + if err := rejectUnsupportedDimensions(params, hookdeck.DefaultEventRouteDimensions, hookdeck.EventRouteDefault); err != nil { return nil, err } return client.QueryEventMetrics(ctx, params) diff --git a/pkg/cmd/metrics_events_routing_test.go b/pkg/cmd/metrics_events_routing_test.go index 070e79bf..370c5afc 100644 --- a/pkg/cmd/metrics_events_routing_test.go +++ b/pkg/cmd/metrics_events_routing_test.go @@ -255,3 +255,38 @@ func TestCompatibleMeasureAndDimensionStillRoute(t *testing.T) { assert.Contains(t, *path, "events-by-issue") }) } + +// TestEveryRoutedMeasureDispatchesWhereTheSharedTableSays is the dispatch half +// of hookdeck.TestRouteForMeasuresIsTheOneRoutingTable. +// +// Which measures are queue-depth measures used to be written down three times: +// a map here, a containsAny list in the MCP tool, and the table in pkg/hookdeck +// that the cross-route refusal reads. They agreed, but a divergence would +// refuse a mix on the table's reading while dispatching it on this one, so the +// error and the request would disagree about what was asked for. Each measure +// is checked on its own, because a copy that is merely incomplete still routes +// the measures it does list correctly. +func TestEveryRoutedMeasureDispatchesWhereTheSharedTableSays(t *testing.T) { + tests := []struct { + measure string + path string + }{ + {"queue_depth", "queue-depth"}, + {"max_depth", "queue-depth"}, + {"max_age", "queue-depth"}, + {"pending", "pending"}, + {"count", "metrics/events"}, + {"failed_count", "metrics/events"}, + {"error_rate", "metrics/events"}, + } + for _, tt := range tests { + t.Run(tt.measure, func(t *testing.T) { + client, path, _ := routeCapture(t) + _, err := queryEventMetricsConsolidated(context.Background(), client, + hookdeck.MetricsQueryParams{Measures: []string{tt.measure}}) + require.NoError(t, err) + assert.Contains(t, *path, tt.path, + "--measures %s must reach the endpoint the shared routing table names", tt.measure) + }) + } +} diff --git a/pkg/cmd/reference_metrics_doc_test.go b/pkg/cmd/reference_metrics_doc_test.go new file mode 100644 index 00000000..08e0e5cd --- /dev/null +++ b/pkg/cmd/reference_metrics_doc_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// metricsCommandInReference matches a `hookdeck gateway metrics ` +// invocation written in REFERENCE.md prose. +var metricsCommandInReference = regexp.MustCompile(`hookdeck gateway metrics ([a-z][a-z-]*)`) + +// TestReferenceMetricsExamplesNameRealSubcommands guards the one part of the +// Metrics section that no generator maintains. +// +// It sits outside the markers, so it drifted: it documented +// `metrics queue-depth`, `metrics pending` and `metrics events-by-issue` long +// after those were consolidated into `metrics events`, and contradicted the +// paragraph directly below it describing that consolidation. Anyone following +// the table got "unknown command". +func TestReferenceMetricsExamplesNameRealSubcommands(t *testing.T) { + path, err := filepath.Abs(filepath.Join("..", "..", "REFERENCE.md")) + require.NoError(t, err) + body, err := os.ReadFile(path) + require.NoError(t, err) + + real := map[string]bool{} + var names []string + for _, sub := range newMetricsCmd().cmd.Commands() { + real[sub.Name()] = true + names = append(names, sub.Name()) + } + sort.Strings(names) + require.NotEmpty(t, names) + + var documented []string + for _, m := range metricsCommandInReference.FindAllStringSubmatch(string(body), -1) { + documented = append(documented, m[1]) + assert.True(t, real[m[1]], + "REFERENCE.md documents `hookdeck gateway metrics %s`, which is not a subcommand; the real ones are %v", + m[1], names) + } + assert.NotEmpty(t, documented, "the examples should still be there to check") +} diff --git a/pkg/cmd/request_events.go b/pkg/cmd/request_events.go index cc6390a6..77227f49 100644 --- a/pkg/cmd/request_events.go +++ b/pkg/cmd/request_events.go @@ -75,7 +75,7 @@ Examples: rc.cmd.Flags().StringVar(&rc.sourceID, "source-id", "", "Filter by source ID") rc.cmd.Flags().StringVar(&rc.destinationID, "destination-id", "", "Filter by destination ID") rc.cmd.Flags().StringVar(&rc.deliveryGroup, "delivery-group", "", "Filter by delivery group") - rc.cmd.Flags().StringVar(&rc.status, "status", "", "Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED)") + rc.cmd.Flags().StringVar(&rc.status, "status", "", eventStatusFlag.usage()) rc.cmd.Flags().StringVar(&rc.attempts, "attempts", "", "Filter by number of attempts (integer or operators)") rc.cmd.Flags().StringVar(&rc.responseStatus, "response-status", "", "Filter by HTTP response status (e.g. 200, 500)") rc.cmd.Flags().StringVar(&rc.errorCode, "error-code", "", "Filter by error code") @@ -106,6 +106,13 @@ func (rc *requestEventsCmd) runRequestEventsCmd(cmd *cobra.Command, args []strin return err } + // This route shares the /events filter set, so it shares its enum and its + // case sensitivity too. + status, err := eventStatusFlag.canonical(rc.status) + if err != nil { + return err + } + requestID := args[0] client := Config.GetAPIClient() ctx := context.Background() @@ -129,8 +136,8 @@ func (rc *requestEventsCmd) runRequestEventsCmd(cmd *cobra.Command, args []strin if rc.deliveryGroup != "" { params["delivery_group"] = rc.deliveryGroup } - if rc.status != "" { - params["status"] = rc.status + if status != "" { + params["status"] = status } if rc.attempts != "" { params["attempts"] = rc.attempts diff --git a/pkg/cmd/request_list.go b/pkg/cmd/request_list.go index 1fdd6905..d27c9034 100644 --- a/pkg/cmd/request_list.go +++ b/pkg/cmd/request_list.go @@ -15,25 +15,25 @@ import ( type requestListCmd struct { cmd *cobra.Command - id string - sourceID string - status string - verified string - rejectionCause string - createdAfter string - createdBefore string - ingestedAfter string - ingestedBefore string - headers string - body string - path string - parsedQuery string - orderBy string - dir string - limit int - next string - prev string - output string + id string + sourceID string + status string + verified string + rejectionCause string + createdAfter string + createdBefore string + ingestedAfter string + ingestedBefore string + headers string + body string + path string + parsedQuery string + orderBy string + dir string + limit int + next string + prev string + output string } func newRequestListCmd() *requestListCmd { @@ -53,7 +53,7 @@ Examples: rc.cmd.Flags().StringVar(&rc.id, "id", "", "Filter by request ID(s) (comma-separated)") rc.cmd.Flags().StringVar(&rc.sourceID, "source-id", "", "Filter by source ID") - rc.cmd.Flags().StringVar(&rc.status, "status", "", "Filter by status") + rc.cmd.Flags().StringVar(&rc.status, "status", "", requestStatusFlag.usage()) rc.cmd.Flags().StringVar(&rc.verified, "verified", "", "Filter by verified (true/false)") rc.cmd.Flags().StringVar(&rc.rejectionCause, "rejection-cause", "", "Filter by rejection cause") rc.cmd.Flags().StringVar(&rc.createdAfter, "created-after", "", "Filter requests created after (ISO date-time)") @@ -79,6 +79,14 @@ func (rc *requestListCmd) runRequestListCmd(cmd *cobra.Command, args []string) e return err } + // The request log's enum is lower case and the event log's is upper case, + // and the API refuses either in the other's case. Canonicalise so both + // spellings work here, as they already do through MCP. + status, err := requestStatusFlag.canonical(rc.status) + if err != nil { + return err + } + client := Config.GetAPIClient() params := make(map[string]string) if rc.id != "" { @@ -87,8 +95,8 @@ func (rc *requestListCmd) runRequestListCmd(cmd *cobra.Command, args []string) e if rc.sourceID != "" { params["source_id"] = rc.sourceID } - if rc.status != "" { - params["status"] = rc.status + if status != "" { + params["status"] = status } if rc.verified != "" { params["verified"] = rc.verified diff --git a/pkg/cmd/status_flag.go b/pkg/cmd/status_flag.go new file mode 100644 index 00000000..1082a6ab --- /dev/null +++ b/pkg/cmd/status_flag.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// statusFlagVocabulary is the status enum one command's --status filters by, +// paired with the sibling command that takes the other one. +// +// The two log collections disagree on both vocabulary and case: GET /requests +// describes what happened to a request at the edge and spells its enum lower +// case, while GET /events and GET /requests/{id}/events describe where a +// delivery is in its lifecycle and spell theirs upper case. The API rejects +// either mistake with a 422 that names only the enum of the route it was sent +// to. +// +// MCP canonicalises through hookdeck.CanonicalStatusValue and the CLI did not, +// so in one release `hookdeck_requests {action:"list", status:"ACCEPTED"}` +// succeeded and `hookdeck gateway request list --status ACCEPTED` was a 422: +// same contract, two surfaces, two answers. +type statusFlagVocabulary struct { + values []string + // other is the sibling vocabulary, named in the error so a caller who + // reached for the wrong command is told which one takes the value. + other []string + otherCommand string +} + +var ( + // eventStatusFlag is the vocabulary of the commands that read the event + // collection: `gateway event list` and `gateway request events`. + eventStatusFlag = statusFlagVocabulary{ + values: hookdeck.EventStatusValueList, + other: hookdeck.RequestLogStatusValueList, + otherCommand: "gateway request list", + } + + // requestStatusFlag is the vocabulary of `gateway request list`. + requestStatusFlag = statusFlagVocabulary{ + values: hookdeck.RequestLogStatusValueList, + other: hookdeck.EventStatusValueList, + otherCommand: "gateway event list", + } +) + +// usage renders the --status help for this command, so what is advertised and +// what is accepted are the same list. +func (v statusFlagVocabulary) usage() string { + return fmt.Sprintf("Filter by status (%s)", hookdeck.ValueList(v.values)) +} + +// canonical returns the value to send, in the API's own spelling, or an error +// naming the vocabulary this command does filter by. An empty value means the +// flag was not given. +func (v statusFlagVocabulary) canonical(value string) (string, error) { + if value == "" { + return "", nil + } + if canonical, ok := hookdeck.CanonicalStatusValue(v.values, value); ok { + return canonical, nil + } + msg := fmt.Sprintf("--status %q is not supported by this command; it filters by %s", + value, hookdeck.ValueList(v.values)) + if _, ok := hookdeck.CanonicalStatusValue(v.other, value); ok { + msg += fmt.Sprintf(". It belongs to `hookdeck %s`, which filters by %s", + v.otherCommand, hookdeck.ValueList(v.other)) + } + return "", errors.New(msg) +} diff --git a/pkg/cmd/status_flag_test.go b/pkg/cmd/status_flag_test.go new file mode 100644 index 00000000..ded30eee --- /dev/null +++ b/pkg/cmd/status_flag_test.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// logStatusStub serves any log collection and records the query it was asked +// for. +func logStatusStub(t *testing.T, query *url.Values) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(hookdeck.EventListResponse{}) + })) + t.Cleanup(server.Close) + return server +} + +// pointCommandAt aims the process-wide API client singleton at the stub for the +// duration of one test. +func pointCommandAt(t *testing.T, server *httptest.Server) { + t.Helper() + old := Config + t.Cleanup(func() { Config = old }) + config.ResetAPIClientForTesting() + t.Cleanup(config.ResetAPIClientForTesting) + + Config = config.Config{} + Config.APIBaseURL = server.URL + Config.Profile.APIKey = "sk_test_123456789012" + Config.Profile.ProjectId = "proj_1" +} + +// TestCLIStatusIsCanonicalisedPerCommand pins the CLI half of a contract that +// only MCP was honouring. +// +// pkg/hookdeck/status.go exists so both layers can name which vocabulary they +// mean, and only the MCP tools called it: in the same release +// `hookdeck_requests {action:"list", status:"ACCEPTED"}` succeeded while +// `hookdeck gateway request list --status ACCEPTED` came back a 422, because +// the request log's enum is lower case and the API checks the case. +func TestCLIStatusIsCanonicalisedPerCommand(t *testing.T) { + // run invokes one command with --status set and returns what reached the + // API, or the error that stopped it. + type runner struct { + name string + path string + run func(t *testing.T, value string) error + } + runners := []runner{ + { + name: "gateway request list", + path: hookdeck.APIPathPrefix + "/requests", + run: func(t *testing.T, value string) error { + rc := newRequestListCmd() + require.NoError(t, rc.cmd.Flags().Set("status", value)) + return rc.runRequestListCmd(rc.cmd, nil) + }, + }, + { + name: "gateway event list", + path: hookdeck.APIPathPrefix + "/events", + run: func(t *testing.T, value string) error { + ec := newEventListCmd() + require.NoError(t, ec.cmd.Flags().Set("status", value)) + return ec.runEventListCmd(ec.cmd, nil) + }, + }, + { + name: "gateway request events", + path: hookdeck.APIPathPrefix + "/requests/req_1/events", + run: func(t *testing.T, value string) error { + rc := newRequestEventsCmd() + require.NoError(t, rc.cmd.Flags().Set("status", value)) + return rc.runRequestEventsCmd(rc.cmd, []string{"req_1"}) + }, + }, + } + + // forwards[i] is keyed by the runner name: the value the user types and the + // spelling the API has to be sent. + forwards := map[string][][2]string{ + "gateway request list": { + {"accepted", "accepted"}, + {"ACCEPTED", "accepted"}, + {"Rejected", "rejected"}, + }, + "gateway event list": { + {"SUCCESSFUL", "SUCCESSFUL"}, + {"failed", "FAILED"}, + {"Cancelled", "CANCELLED"}, + }, + "gateway request events": { + {"SUCCESSFUL", "SUCCESSFUL"}, + {"failed", "FAILED"}, + }, + } + + // rejects[i] is a value from the sibling collection's vocabulary, which the + // API would answer with a 422 naming only the enum it was sent. + rejects := map[string]struct { + value string + elsewhere string + }{ + "gateway request list": {"SUCCESSFUL", "gateway event list"}, + "gateway event list": {"accepted", "gateway request list"}, + "gateway request events": {"rejected", "gateway request list"}, + } + + for _, r := range runners { + for _, pair := range forwards[r.name] { + t.Run(r.name+" sends "+pair[0]+" as "+pair[1], func(t *testing.T) { + var query url.Values + server := logStatusStub(t, &query) + pointCommandAt(t, server) + + require.NoError(t, r.run(t, pair[0])) + assert.Equal(t, pair[1], query.Get("status"), + "the API checks the case of its enum, so --status has to be canonicalised") + }) + } + + t.Run(r.name+" refuses the sibling vocabulary", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with a status from the other collection", r.URL.Path) + })) + t.Cleanup(server.Close) + pointCommandAt(t, server) + + tt := rejects[r.name] + err := r.run(t, tt.value) + require.Error(t, err, "%q belongs to another collection", tt.value) + assert.Contains(t, err.Error(), tt.value) + assert.Contains(t, err.Error(), tt.elsewhere, + "the error should name the command that does take it") + }) + } +} + +// TestStatusFlagUsageNamesTheVocabularyItAccepts keeps --help and the check in +// step. The event commands spelled the enum out by hand and `request list` +// named no vocabulary at all, which is how "--status ACCEPTED" looked like a +// reasonable thing to type. +func TestStatusFlagUsageNamesTheVocabularyItAccepts(t *testing.T) { + assert.Contains(t, newRequestListCmd().cmd.Flags().Lookup("status").Usage, + hookdeck.RequestLogStatusValues) + assert.Contains(t, newEventListCmd().cmd.Flags().Lookup("status").Usage, + hookdeck.EventStatusValues) + assert.Contains(t, newRequestEventsCmd().cmd.Flags().Lookup("status").Usage, + hookdeck.EventStatusValues) +} diff --git a/pkg/gateway/mcp/tool_events.go b/pkg/gateway/mcp/tool_events.go index 6182f886..1c7310a1 100644 --- a/pkg/gateway/mcp/tool_events.go +++ b/pkg/gateway/mcp/tool_events.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "errors" "fmt" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -42,7 +43,37 @@ func handleEvents(client *hookdeck.Client) mcpsdk.ToolHandler { } } +// canonicalEventsStatus returns the status to send to GET /events, in the API's +// own spelling. +// +// hookdeck_events queries the same collection as hookdeck_requests action +// "events", so it has to accept the same values: that action canonicalises and +// this one forwarded the raw string, which meant `hookdeck_requests +// {action:"events", status:"failed"}` worked and `hookdeck_events +// {action:"list", status:"failed"}` came back a 422 from the same enum. +func canonicalEventsStatus(value string) (string, error) { + if value == "" { + return "", nil + } + if canonical, ok := hookdeck.CanonicalStatusValue(hookdeck.EventStatusValueList, value); ok { + return canonical, nil + } + msg := fmt.Sprintf("status %q is not supported by hookdeck_events; it filters by %s", + value, hookdeck.ValueList(hookdeck.EventStatusValueList)) + // The request log's vocabulary is the one a caller reaches for by mistake, + // and the API's 422 would only ever name the enum it was sent to. + if _, ok := hookdeck.CanonicalStatusValue(hookdeck.RequestLogStatusValueList, value); ok { + msg += fmt.Sprintf(". It is a request status, which hookdeck_requests action \"list\" filters by: %s", + hookdeck.RequestLogStatusValues) + } + return "", errors.New(msg) +} + func eventsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk.CallToolResult, error) { + status, err := canonicalEventsStatus(in.String("status")) + if err != nil { + return ErrorResult(err.Error()), nil + } params := make(map[string]string) setIfNonEmpty(params, "id", in.String("id")) // connection_id maps to webhook_id in the API @@ -50,7 +81,7 @@ func eventsList(ctx context.Context, client *hookdeck.Client, in input) (*mcpsdk setIfNonEmpty(params, "source_id", in.String("source_id")) setIfNonEmpty(params, "destination_id", in.String("destination_id")) setIfNonEmpty(params, "delivery_group", in.String("delivery_group")) - setIfNonEmpty(params, "status", in.String("status")) + setIfNonEmpty(params, "status", status) setIfNonEmpty(params, "attempts", in.String("attempts")) setIfNonEmpty(params, "issue_id", in.String("issue_id")) setIfNonEmpty(params, "error_code", in.String("error_code")) diff --git a/pkg/gateway/mcp/tool_metrics.go b/pkg/gateway/mcp/tool_metrics.go index a6b1440d..032234ad 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -123,13 +123,18 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp // Route to the correct events metrics endpoint based on measures/dimensions. // Each route accepts a different set of filters, so the ones it would ignore // are refused here rather than silently dropped by the API. + // Which measures belong to which endpoint is the shared table's to know, and + // the route names are its constants, so this cannot drift from the refusal + // above or from the CLI's copy of the same switch. + measureRoute := hookdeck.RouteForMeasures(params.Measures) + var result hookdeck.MetricsResponse switch { - case containsAny(params.Measures, "queue_depth", "max_depth", "max_age"): - if err := rejectFilters(params, hookdeck.QueueDepthRouteFilters, "queue depth metrics"); err != nil { + case measureRoute == hookdeck.EventRouteQueueDepth: + if err := rejectFilters(params, hookdeck.QueueDepthRouteFilters, hookdeck.EventRouteQueueDepth); err != nil { return ErrorResult(err.Error()), nil } - if err := rejectDimensions(params, hookdeck.QueueDepthRouteDimensions, "queue depth metrics"); err != nil { + if err := rejectDimensions(params, hookdeck.QueueDepthRouteDimensions, hookdeck.EventRouteQueueDepth); err != nil { return ErrorResult(err.Error()), nil } // The endpoint accepts max_depth and max_age only; "queue_depth" is our @@ -137,11 +142,11 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp queueParams := params queueParams.Measures = hookdeck.TranslateQueueDepthMeasures(params.Measures) result, err = client.QueryQueueDepth(ctx, queueParams) - case containsAny(params.Measures, "pending"): - if err := rejectFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (measures: pending)"); err != nil { + case measureRoute == hookdeck.EventRoutePending: + if err := rejectFilters(params, hookdeck.PendingTimeseriesRouteFilters, hookdeck.EventRoutePending); err != nil { return ErrorResult(err.Error()), nil } - if err := rejectDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, "pending event metrics (measures: pending)"); err != nil { + if err := rejectDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, hookdeck.EventRoutePending); err != nil { return ErrorResult(err.Error()), nil } // The API expects measures[]=count here; "pending" only selects the @@ -154,10 +159,10 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp if params.IssueID == "" { return ErrorResult("per-issue metrics require issue_id (required when using dimensions: issue_id)"), nil } - if err := rejectFilters(params, hookdeck.EventsByIssueRouteFilters, "per-issue event metrics"); err != nil { + if err := rejectFilters(params, hookdeck.EventsByIssueRouteFilters, hookdeck.EventRouteByIssue); err != nil { return ErrorResult(err.Error()), nil } - if err := rejectDimensions(params, hookdeck.EventsByIssueRouteDimensions, "per-issue event metrics"); err != nil { + if err := rejectDimensions(params, hookdeck.EventsByIssueRouteDimensions, hookdeck.EventRouteByIssue); err != nil { return ErrorResult(err.Error()), nil } result, err = client.QueryEventsByIssue(ctx, params) @@ -168,7 +173,7 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp // invariant is pinned by // hookdeck.TestDefaultEventRouteHonoursEveryFilterExceptIssueID, which // fails if a filter the route drops is ever added. - if err := rejectDimensions(params, hookdeck.DefaultEventRouteDimensions, "event metrics"); err != nil { + if err := rejectDimensions(params, hookdeck.DefaultEventRouteDimensions, hookdeck.EventRouteDefault); err != nil { return ErrorResult(err.Error()), nil } result, err = client.QueryEventMetrics(ctx, params) diff --git a/pkg/gateway/mcp/tool_requests_events_filters_test.go b/pkg/gateway/mcp/tool_requests_events_filters_test.go index 42440db4..0ee131c6 100644 --- a/pkg/gateway/mcp/tool_requests_events_filters_test.go +++ b/pkg/gateway/mcp/tool_requests_events_filters_test.go @@ -173,3 +173,52 @@ func TestRequestsStatusDescriptionNamesBothVocabularies(t *testing.T) { assert.Contains(t, desc, "list") assert.Contains(t, desc, "events") } + +// TestEventsStatusIsCanonicalisedLikeTheRequestsTool covers the other half of +// the same vocabulary. hookdeck_events action "list" and hookdeck_requests +// action "events" query the same collection through the same status enum, and +// only the latter canonicalised: `status: "failed"` worked on one tool and came +// back as an API 422 on the other. +func TestEventsStatusIsCanonicalisedLikeTheRequestsTool(t *testing.T) { + forwards := []struct { + name string + value string + want string + }{ + {"canonical spelling is forwarded", "SUCCESSFUL", "SUCCESSFUL"}, + {"lower case is canonicalised", "failed", "FAILED"}, + {"mixed case is canonicalised", "Cancelled", "CANCELLED"}, + } + for _, tt := range forwards { + t.Run(tt.name, func(t *testing.T) { + var saw string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { + saw = r.URL.Query().Get("status") + _ = json.NewEncoder(w).Encode(listResponse()) + }, + }) + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", "status": tt.value, + }) + assert.False(t, result.IsError, textContent(t, result)) + assert.Equal(t, tt.want, saw, "status must reach the API in the spelling the enum uses") + }) + } + + t.Run("a request-log status is refused and points at the tool that takes it", func(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call %s with a status from the request-log vocabulary", r.URL.Path) + }, + }) + result := callTool(t, session, "hookdeck_events", map[string]any{ + "action": "list", "status": "accepted", + }) + require.True(t, result.IsError, "a request status must be refused on hookdeck_events") + body := textContent(t, result) + assert.Contains(t, body, "accepted") + assert.Contains(t, body, hookdeck.EventStatusValues, "the error must name the vocabulary this tool does take") + assert.Contains(t, body, "hookdeck_requests", "the error should name the tool that takes it") + }) +} diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 48d60b5f..aab40982 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -320,10 +320,15 @@ func (c *Client) Put(ctx context.Context, path string, data []byte, configure fu // data[] entries are seen both as plain strings and as objects with a message // field, so both are read. Returns "" when nothing readable is found, leaving // the caller to fall back to the raw body. +// +// "data" is held as a raw value rather than decoded straight into a slice, so +// a body whose data is an object or a scalar cannot fail the whole unmarshal +// and throw the top-level "message" away with it - which would dump the entire +// raw body at the caller, the exact outcome this function exists to prevent. func apiErrorMessage(body []byte) string { var payload struct { - Message string `json:"message"` - Data []json.RawMessage `json:"data"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &payload); err != nil { return "" @@ -331,25 +336,39 @@ func apiErrorMessage(body []byte) string { if payload.Message != "" { return payload.Message } - messages := make([]string, 0, len(payload.Data)) - for _, item := range payload.Data { - var text string - if err := json.Unmarshal(item, &text); err == nil { - if text != "" { - messages = append(messages, text) - } - continue - } - var obj struct { - Message string `json:"message"` - } - if err := json.Unmarshal(item, &obj); err == nil && obj.Message != "" { - messages = append(messages, obj.Message) + if len(payload.Data) == 0 { + return "" + } + var items []json.RawMessage + if err := json.Unmarshal(payload.Data, &items); err != nil { + // Not a list: read the value itself the way a single entry is read. + return errorDataMessage(payload.Data) + } + messages := make([]string, 0, len(items)) + for _, item := range items { + if msg := errorDataMessage(item); msg != "" { + messages = append(messages, msg) } } return strings.Join(messages, "; ") } +// errorDataMessage reads one error-data value, which is seen both as a plain +// string and as an object with a message field. +func errorDataMessage(raw json.RawMessage) string { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return text + } + var obj struct { + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &obj); err == nil { + return obj.Message + } + return "" +} + func checkAndPrintError(res *http.Response) error { if res.StatusCode != http.StatusOK { if res.Body != nil { diff --git a/pkg/hookdeck/client_error_message_test.go b/pkg/hookdeck/client_error_message_test.go index 9178e3aa..b14374ed 100644 --- a/pkg/hookdeck/client_error_message_test.go +++ b/pkg/hookdeck/client_error_message_test.go @@ -45,6 +45,35 @@ func TestAPIErrorMessageSurfacesTheUsefulLine(t *testing.T) { body: `{"message":"Not found","data":["ignored"],"status":404}`, want: "Not found", }, + { + // The regression this shape caused: decoding data straight into a + // []json.RawMessage failed the whole unmarshal on a non-array data, + // so the message beside it was lost and the caller pasted the raw + // body instead. + name: "a message survives an object data", + body: `{"message":"Invalid API key","data":{"field":"api_key"},"status":401}`, + want: "Invalid API key", + }, + { + name: "a message survives a string data", + body: `{"message":"Not found","data":"nothing here"}`, + want: "Not found", + }, + { + name: "an object data with a message of its own is still read", + body: `{"data":{"message":"nested problem"},"status":422}`, + want: "nested problem", + }, + { + name: "a bare string data is read", + body: `{"data":"just this","status":422}`, + want: "just this", + }, + { + name: "a data carrying nothing readable falls back to the caller", + body: `{"data":{"field":"api_key"},"status":422}`, + want: "", + }, { name: "nothing readable falls back to the caller", body: `{"status":500}`, diff --git a/pkg/hookdeck/metrics_dimensions_test.go b/pkg/hookdeck/metrics_dimensions_test.go index 531c7a6f..1901849d 100644 --- a/pkg/hookdeck/metrics_dimensions_test.go +++ b/pkg/hookdeck/metrics_dimensions_test.go @@ -75,6 +75,7 @@ func TestRejectUnsupportedDimensions(t *testing.T) { allowed []string wantErr bool contains []string + excludes []string }{ { name: "no dimensions is always fine", @@ -110,6 +111,27 @@ func TestRejectUnsupportedDimensions(t *testing.T) { params: MetricsQueryParams{Dimensions: []string{"connection_id"}}, allowed: DefaultEventRouteDimensions, }, + { + // Both callers rewrite connection_id to webhook_id before + // validating, so this is what actually arrives here. Naming the + // wire spelling back refused "webhook_id" at a caller who typed + // connection_id, in the same sentence as an allowed list that + // spells it connection_id. + name: "a refused connection dimension is named as the caller spells it", + params: MetricsQueryParams{Dimensions: []string{"webhook_id"}}, + allowed: PendingTimeseriesRouteDimensions, + wantErr: true, + contains: []string{"--dimensions", `"connection_id"`, "destination_id"}, + excludes: []string{"webhook_id"}, + }, + { + name: "and the same when the caller's own spelling arrives unmapped", + params: MetricsQueryParams{Dimensions: []string{"connection_id"}}, + allowed: PendingTimeseriesRouteDimensions, + wantErr: true, + contains: []string{`"connection_id"`}, + excludes: []string{"webhook_id"}, + }, } for _, tt := range tests { @@ -123,6 +145,10 @@ func TestRejectUnsupportedDimensions(t *testing.T) { for _, want := range tt.contains { assert.Contains(t, err.Error(), want) } + for _, unwanted := range tt.excludes { + assert.NotContains(t, err.Error(), unwanted, + "the error must not name a token the caller never typed") + } }) } } @@ -174,3 +200,55 @@ func TestEveryEventMeasureRoutes(t *testing.T) { assert.True(t, known, "advertised measure %q has no route", m) } } + +// TestRouteForMeasuresIsTheOneRoutingTable pins the membership both callers now +// read instead of keeping a copy. +// +// `metrics events` had three encodings of "which measures are queue depth": a +// map in the CLI, a containsAny list in MCP, and this table. They agreed, but a +// divergence between any two would refuse a mix against one and dispatch it to +// the wrong endpoint against the other - the failure mode being that the +// refusal and the dispatch disagree about what was asked for. +func TestRouteForMeasuresIsTheOneRoutingTable(t *testing.T) { + tests := []struct { + measures []string + want string + }{ + {[]string{"queue_depth"}, EventRouteQueueDepth}, + {[]string{"max_depth"}, EventRouteQueueDepth}, + {[]string{"max_age"}, EventRouteQueueDepth}, + {[]string{"max_depth", "max_age"}, EventRouteQueueDepth}, + {[]string{"pending"}, EventRoutePending}, + {[]string{"count"}, EventRouteDefault}, + {[]string{"error_rate", "failed_count"}, EventRouteDefault}, + // A measure this package does not route on leaves the decision to the + // dimensions, and the API to reject the measure itself. + {[]string{"not_a_measure"}, ""}, + {[]string{"not_a_measure", "max_age"}, EventRouteQueueDepth}, + {nil, ""}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, RouteForMeasures(tt.measures), "measures %v", tt.measures) + } + + // Every advertised measure has to land on a route the caller can name, or + // the guards that print the route name print an empty string. + for _, m := range EventMetricsMeasureValues { + route := RouteForMeasures([]string{m}) + assert.NotEmpty(t, route, "advertised measure %q routes nowhere", m) + } +} + +// TestEventRouteNamesAreUnique is the other half of the tidy-up: every guard +// used to hand-write the route name, which produced two names for one route in +// adjacent errors ("pending event metrics" from the cross-route refusal, +// "pending event metrics (--measures pending)" from the filter guard beside +// it). One constant per route means one name per route. +func TestEventRouteNamesAreUnique(t *testing.T) { + seen := map[string]bool{} + for _, name := range []string{EventRouteDefault, EventRouteQueueDepth, EventRoutePending, EventRouteByIssue} { + assert.NotEmpty(t, name) + assert.False(t, seen[name], "%q names two routes", name) + seen[name] = true + } +} diff --git a/pkg/hookdeck/metrics_filters.go b/pkg/hookdeck/metrics_filters.go index 5aba66f1..6878a8f2 100644 --- a/pkg/hookdeck/metrics_filters.go +++ b/pkg/hookdeck/metrics_filters.go @@ -176,14 +176,25 @@ func ValueList(values []string) string { func DimensionList(values []string) string { out := make([]string, len(values)) for i, v := range values { - if v == "webhook_id" { - v = "connection_id" - } - out[i] = v + out[i] = DimensionName(v) } return strings.Join(out, ", ") } +// DimensionName renders one dimension in the caller's spelling, the inverse of +// the connection_id -> webhook_id mapping both callers apply on the way in. +// +// Anything reported back to the caller has to go through this, or a refusal +// names a token the caller never typed: params.Dimensions holds webhook_id by +// the time it is validated, while the allowed list in the same sentence is +// rendered by DimensionList and says connection_id. +func DimensionName(value string) string { + if value == "webhook_id" { + return "connection_id" + } + return value +} + // unionValues concatenates vocabularies, keeping first-seen order and dropping // duplicates, so a union list cannot drift from the routes it is built from. func unionValues(lists ...[]string) []string { @@ -214,6 +225,13 @@ func containsValue(values []string, want string) bool { // define, plus the API's one cross-field rule: grouping by delivery_group needs // a destination_id filter. // +// That rule is not events-only. The API enforces it on every route that offers +// the dimension, and this function is called from all of them. Checked live on +// 2026-09-14 against the attempts route, which is not an events route at all: +// `metrics attempts --measures count --dimensions delivery_group` answers 422 +// "The delivery_group dimension requires a filters.destination_id filter", and +// the same call with --destination-id answers 200. +// // Without this the caller sees a raw 422 for something the tool appeared to // offer - and on `dimensions: ["delivery_group"]` that is the release's // headline feature looking broken. dimensionsName is the caller's own spelling @@ -225,8 +243,11 @@ func RejectUnsupportedDimensions(params MetricsQueryParams, allowed []string, ro d = "webhook_id" } if !containsValue(allowed, d) { + // Reported in the caller's spelling, not the API's: both callers + // rewrite connection_id to webhook_id before validating, so naming + // d raw refused "webhook_id" at someone who typed connection_id. return fmt.Errorf("%s %q is not supported by %s; that route groups by: %s", - dimensionsName, d, route, DimensionList(allowed)) + dimensionsName, DimensionName(d), route, DimensionList(allowed)) } } if containsValue(params.Dimensions, "delivery_group") && params.DestinationID == "" { @@ -295,6 +316,24 @@ var eventDimensionRoutes = map[string]string{ "issue_id": EventRouteByIssue, } +// RouteForMeasures returns the events-metrics route this measure list selects, +// or "" when none of the measures decides the route and the request falls +// through to whatever the dimensions select. +// +// It is the one place that knows which measures belong to which endpoint. Both +// callers used to hold their own copy of the queue-depth membership - a map in +// the CLI, a containsAny list in MCP - beside this table, and a divergence +// between any two of the three would refuse a mix here while still dispatching +// it to the wrong endpoint there. +// +// Call RejectMixedMeasureRoutes (or RejectCrossRouteEventQuery, which subsumes +// it) first: a list spanning two routes has no single answer, and this reports +// whichever route its first routed measure names. +func RouteForMeasures(measures []string) string { + _, route := firstRoutedMeasure(measures) + return route +} + // firstRoutedMeasure returns the first measure that selects an endpoint, and the // endpoint it selects. ("", "") means the measures do not decide the route — the // request falls through to whatever the dimensions select, or to the default. From b6e621c6cfa69732f07190b7a7f2017eb943ff5e Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 15 Sep 2026 00:11:44 +0100 Subject: [PATCH 16/16] revert: defer the --config mutual-exclusion to 3.0.0 faa742f made --config/--config-file a refused conflict with the individual destination config flags on `destination create`, `update` and `upsert`. That is a breaking change: before it, destination create --type HTTP --config '{"url":"https://from-config"}' \ --url https://from-flag succeeded and sent url=https://from-flag; after it, the command errors. 2.6.0 is not the release for that, so the change comes out here and will be re-proposed against 3.0.0 on its own. Restored from faa742f^, hunk for hunk: - rejectConfigJSONWithIndividualFlags and destinationIndividualConfigFlags in destination_common.go, and the three validateFlags call sites in destination_create.go, destination_update.go and destination_upsert.go. - The two overlays the rejection made unreachable: the --url overlay in buildCreateRequest (its applyCLIPath call was never removed and stays), and the --url/--cli-path overlay in buildUpsertRequest. - The four tests that pinned the refusal: TestDestinationConfigJSONRefusesIndividualFlags, TestDestinationConfigFileRefusesIndividualFlagsToo, TestDestinationConfigJSONAloneIsStillAccepted and TestDestinationUpsertAndUpdateAgreeOnConfigJSON, with the helpers added for them. The five builder tests that predate faa742f are untouched. - The --config/--config-file help text on all three commands, and REFERENCE.md regenerated to match. This restores the pre-faa742f behaviour as it was, three-way disagreement included: `create` and `upsert --type HTTP` let --url win, `update` and `upsert` without --type let --config win. Fixing that is the 3.0.0 proposal, not this commit. The other seven findings in faa742f stay: the RejectUnsupportedDimensions comment, hookdeck.DimensionName and the caller's-spelling refusal, canonicalEventsStatus, the apiErrorMessage raw-data fix, status_flag.go and its three callers, hookdeck.RouteForMeasures and the route-name constants, and REFERENCE.md's corrected metrics prose with its test. metrics_filters.go was picked over by hand rather than reverted, so the comment correction it shares with this file set survives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9 --- REFERENCE.md | 12 +- pkg/cmd/destination_common.go | 58 +-------- pkg/cmd/destination_config_json_test.go | 153 ------------------------ pkg/cmd/destination_create.go | 25 ++-- pkg/cmd/destination_update.go | 17 +-- pkg/cmd/destination_upsert.go | 35 +++--- 6 files changed, 41 insertions(+), 259 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index f047910a..4c1ba27f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1089,8 +1089,8 @@ hookdeck gateway destination create [flags] | `--basic-auth-user` | `string` | Username for Basic auth | | `--bearer-token` | `string` | Bearer token for destination auth | | `--cli-path` | `string` | Path for CLI destinations (default "/") | -| `--config` | `string` | JSON object for the whole destination config; cannot be combined with the individual config flags | -| `--config-file` | `string` | Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags | +| `--config` | `string` | JSON object for destination config (overrides individual flags if set) | +| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | | `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | @@ -1160,8 +1160,8 @@ hookdeck gateway destination update [flags] | `--basic-auth-user` | `string` | Username for Basic auth | | `--bearer-token` | `string` | Bearer token for destination auth | | `--cli-path` | `string` | Path for CLI destinations | -| `--config` | `string` | JSON object for the whole destination config; cannot be combined with the individual config flags | -| `--config-file` | `string` | Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags | +| `--config` | `string` | JSON object for destination config (overrides individual flags if set) | +| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | | `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | @@ -1228,8 +1228,8 @@ hookdeck gateway destination upsert [flags] | `--basic-auth-user` | `string` | Username for Basic auth | | `--bearer-token` | `string` | Bearer token for destination auth | | `--cli-path` | `string` | Path for CLI destinations | -| `--config` | `string` | JSON object for the whole destination config; cannot be combined with the individual config flags | -| `--config-file` | `string` | Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags | +| `--config` | `string` | JSON object for destination config (overrides individual flags if set) | +| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | | `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go index cc719c4a..54181454 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -13,9 +13,8 @@ import ( ) // destinationConfigFlags holds destination config flags for create/upsert/update. -// Used by destination create, upsert, update. They are an alternative to -// --config/--config-file, not an overlay on it: each input describes the whole -// config, so naming both is refused by rejectConfigJSONWithIndividualFlags. +// Used by destination create, upsert, update. When both --config/--config-file and +// individual flags are set, --config/--config-file take precedence. type destinationConfigFlags struct { URL string CliPath string @@ -298,59 +297,6 @@ var typeSpecificDestinationFlags = []struct { {"cli-path", "CLI", func(f *destinationConfigFlags) bool { return f.CliPath != "" }}, } -// destinationIndividualConfigFlags are the flags that set a field inside the -// destination config, which is exactly what --config and --config-file supply -// wholesale. -var destinationIndividualConfigFlags = []string{ - "url", "cli-path", "http-method", "path-forwarding-disabled", - "auth-method", "bearer-token", "basic-auth-user", "basic-auth-pass", - "api-key", "api-key-header", "api-key-to", - "custom-signature-secret", "custom-signature-key", - "rate-limit", "rate-limit-period", - "delivery-group-key", "delivery-group-rate", "delivery-group-rate-period", - "delivery-group-overrides", -} - -// rejectConfigJSONWithIndividualFlags refuses --config or --config-file next to -// a flag that sets one of the same fields. -// -// The two ways of describing a config disagreed with each other and the three -// commands disagreed about how. --config was documented as winning, and did on -// `update`; `create` and `upsert` then overlaid --url and --cli-path back on -// top of it, but `upsert` only reached that overlay when --type was passed, -// because resolveDestinationType returns early on the --config path. So -// `upsert --config '{"url":"https://old"}' --url https://new` exited 0 having -// sent the old URL, while the same flags with --type HTTP sent the new one, and -// `update` sent the old one either way (the #406 shape, in a corner). -// -// Refusing the combination is what fixes all of that at once. The alternative - -// making the individual flag win everywhere - only reaches the two fields the -// overlays happen to cover: --auth-method, --http-method, --rate-limit and the -// delivery-group flags would still be dropped in silence under --config, and -// merging them in raises questions (what happens to delivery_policy.groups?) -// that nobody has asked for. Either input describes the whole config, so asking -// for one is unambiguous and asking for both never was. -func rejectConfigJSONWithIndividualFlags(cmd *cobra.Command, configStr, configFile string) error { - if configStr == "" && configFile == "" { - return nil - } - jsonFlag := "--config" - if configStr == "" { - jsonFlag = "--config-file" - } - for _, name := range destinationIndividualConfigFlags { - flag := cmd.Flags().Lookup(name) - // Changed, not the value: --api-key-to and --cli-path carry defaults, - // and a default the user never typed is not a conflict. - if flag == nil || !flag.Changed { - continue - } - return fmt.Errorf("--%s cannot be combined with %s: %s supplies the whole config, so put the field in the JSON or drop %s", - name, jsonFlag, jsonFlag, jsonFlag) - } - return nil -} - // hasAnyTypeSpecificFlag reports whether a flag was given that only one // destination type has a field for. func (f *destinationConfigFlags) hasAnyTypeSpecificFlag() bool { diff --git a/pkg/cmd/destination_config_json_test.go b/pkg/cmd/destination_config_json_test.go index 15f1308c..1f7e0b6e 100644 --- a/pkg/cmd/destination_config_json_test.go +++ b/pkg/cmd/destination_config_json_test.go @@ -5,11 +5,8 @@ import ( "path/filepath" "testing" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/hookdeck/hookdeck-cli/pkg/config" ) // TestBuildDestinationConfigFromJSONString verifies that --config (JSON string) parses @@ -169,153 +166,3 @@ func TestBuildDestinationConfigFromJSONFile(t *testing.T) { assert.Contains(t, err.Error(), "config file") }) } - -// withTestAPIKey gives validateFlags a key to accept, so these cases reach the -// flag checks that follow it. -func withTestAPIKey(t *testing.T) { - t.Helper() - old := Config - t.Cleanup(func() { Config = old }) - Config = config.Config{} - Config.Profile.APIKey = "sk_test_123456789012" -} - -// destinationConfigCommand is one of the three commands that accept both a -// config JSON and the individual config flags, reduced to what these cases -// need: set some flags, run the validation the command runs. -type destinationConfigCommand struct { - name string - cmd *cobra.Command - validate func(*cobra.Command, []string) error - args []string -} - -// destinationConfigCommands returns a fresh instance of each command. pflag -// records "was this flag given" on the flag itself, so a command may only be -// used for one case. -func destinationConfigCommands() []destinationConfigCommand { - create := newDestinationCreateCmd() - update := newDestinationUpdateCmd() - upsert := newDestinationUpsertCmd() - return []destinationConfigCommand{ - {"create", create.cmd, create.validateFlags, nil}, - {"update", update.cmd, update.validateFlags, []string{"des_1"}}, - {"upsert", upsert.cmd, upsert.validateFlags, []string{"my-http"}}, - } -} - -// TestDestinationConfigJSONRefusesIndividualFlags pins the contract the three -// commands could not agree on. -// -// --config was documented as overriding the individual flags and did on -// `update`; `create` and `upsert` overlaid --url back on top of it, and -// `upsert` only reached that overlay when --type was passed, because -// resolveDestinationType returns early on the --config path. So -// `upsert my-http --config '{"url":"https://old"}' --url https://new` exited 0 -// having sent https://old with no mention of --url, and the same flags with -// --type HTTP sent https://new while `update` still sent https://old. Either -// input describes the whole config, so asking for both is now a conflict. -func TestDestinationConfigJSONRefusesIndividualFlags(t *testing.T) { - conflicts := []struct { - flag string - value string - }{ - // The reported shape: silently dropped on the --config path. - {"url", "https://new.example.com/hook"}, - {"cli-path", "/webhooks"}, - {"http-method", "PUT"}, - // Never covered by any overlay, so silently dropped on all three - // commands whatever the type. - {"auth-method", "bearer"}, - {"bearer-token", "tok_123"}, - {"rate-limit", "100"}, - {"delivery-group-key", "body.customer_id"}, - } - - for i, cmdUnderTest := range destinationConfigCommands() { - for _, tt := range conflicts { - t.Run(cmdUnderTest.name+" --config with --"+tt.flag, func(t *testing.T) { - withTestAPIKey(t) - c := destinationConfigCommands()[i] - require.NoError(t, c.cmd.Flags().Set("config", `{"url":"https://old.example.com/hook"}`)) - require.NoError(t, c.cmd.Flags().Set(tt.flag, tt.value)) - - err := c.validate(c.cmd, c.args) - require.Error(t, err, "--%s alongside --config was dropped without a word", tt.flag) - assert.Contains(t, err.Error(), "--"+tt.flag) - assert.Contains(t, err.Error(), "--config") - }) - } - } -} - -// TestDestinationConfigFileRefusesIndividualFlagsToo covers the other JSON -// input. --config-file reaches exactly the same builder, so the two have to -// answer the same way. -func TestDestinationConfigFileRefusesIndividualFlagsToo(t *testing.T) { - for i, c := range destinationConfigCommands() { - t.Run(c.name, func(t *testing.T) { - withTestAPIKey(t) - c := destinationConfigCommands()[i] - require.NoError(t, c.cmd.Flags().Set("config-file", "/some/config.json")) - require.NoError(t, c.cmd.Flags().Set("url", "https://new.example.com/hook")) - - err := c.validate(c.cmd, c.args) - require.Error(t, err) - assert.Contains(t, err.Error(), "--url") - assert.Contains(t, err.Error(), "--config-file") - }) - } -} - -// TestDestinationConfigJSONAloneIsStillAccepted is the other half: the refusal -// must key off flags the user actually typed, not off flags that carry a -// default. --api-key-to defaults to "header" on all three commands and -// --cli-path defaults to "/" on create, and neither is something the caller -// asked for. -func TestDestinationConfigJSONAloneIsStillAccepted(t *testing.T) { - for i, c := range destinationConfigCommands() { - t.Run(c.name, func(t *testing.T) { - withTestAPIKey(t) - c := destinationConfigCommands()[i] - require.NoError(t, c.cmd.Flags().Set("config", `{"url":"https://api.example.com/hooks"}`)) - - assert.NoError(t, c.validate(c.cmd, c.args), - "a config JSON on its own is the whole point of the flag") - }) - } -} - -// TestDestinationUpsertAndUpdateAgreeOnConfigJSON states the invariant -// directly, because the two disagreeing is what made the corner hard to see: -// the same flags have to produce the same answer on both commands, with and -// without --type. -func TestDestinationUpsertAndUpdateAgreeOnConfigJSON(t *testing.T) { - for _, declaredType := range []string{"", "HTTP"} { - name := "without --type" - if declaredType != "" { - name = "with --type " + declaredType - } - t.Run(name, func(t *testing.T) { - withTestAPIKey(t) - - update := newDestinationUpdateCmd() - upsert := newDestinationUpsertCmd() - for _, c := range []*cobra.Command{update.cmd, upsert.cmd} { - require.NoError(t, c.Flags().Set("config", `{"url":"https://old.example.com/hook"}`)) - require.NoError(t, c.Flags().Set("url", "https://new.example.com/hook")) - if declaredType != "" { - require.NoError(t, c.Flags().Set("type", declaredType)) - } - } - - updateErr := update.validateFlags(update.cmd, []string{"des_1"}) - upsertErr := upsert.validateFlags(upsert.cmd, []string{"my-http"}) - - require.Error(t, updateErr) - require.Error(t, upsertErr) - assert.Equal(t, updateErr.Error(), upsertErr.Error(), - "update and upsert must not resolve the same flags differently") - }) - } -} diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index 71415d8b..d0028a3f 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -51,8 +51,8 @@ Examples: dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API) (required)") dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations (required for type HTTP)") dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "/", "Path for CLI destinations") - dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for the whole destination config; cannot be combined with the individual config flags") - dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") @@ -85,19 +85,14 @@ func (dc *destinationCreateCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - // --config / --config-file supply the whole config, so an individual config - // flag alongside one of them is a conflict, not an override. Refused rather - // than silently resolved: the three commands resolved it three different - // ways and one of them dropped --url without a word. - if err := rejectConfigJSONWithIndividualFlags(cmd, dc.config, dc.configFile); err != nil { - return err - } t := strings.ToUpper(dc.destType) if t == "HTTP" && dc.url == "" && dc.config == "" && dc.configFile == "" { return fmt.Errorf("--url is required for HTTP destinations") } - // Nothing below applies on the config-JSON path: the individual flags are - // refused above, so there is nothing left for them to validate. + // --config / --config-file take precedence: buildDestinationConfigFromFlags + // returns their JSON and never looks at the individual flags. Validating + // those flags here anyway rejected commands over a value that would have + // been ignored. if dc.config != "" || dc.configFile != "" { return nil } @@ -123,14 +118,14 @@ func (dc *destinationCreateCmd) buildCreateRequest(cmd *cobra.Command) (*hookdec return nil, err } - // --url needs no overlay: the builder above copies it in under type HTTP, - // and an individual flag can no longer arrive beside --config. --cli-path - // still does, for the "/" default that only create supplies - including on - // the --config path, where the JSON may simply not name a path. + // For HTTP/CLI, ensure url/path in config when using individual flags t := strings.ToUpper(dc.destType) if config == nil { config = make(map[string]interface{}) } + if t == "HTTP" && dc.url != "" { + config["url"] = dc.url + } if t == "CLI" { applyCLIPath(config, cliPathFromFlags(cmd, dc.cliPath), true) } diff --git a/pkg/cmd/destination_update.go b/pkg/cmd/destination_update.go index 5b4b19b5..4312c54b 100644 --- a/pkg/cmd/destination_update.go +++ b/pkg/cmd/destination_update.go @@ -49,8 +49,8 @@ Examples: dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API)") dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations") dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "", "Path for CLI destinations") - dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for the whole destination config; cannot be combined with the individual config flags") - dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") @@ -84,15 +84,10 @@ func (dc *destinationUpdateCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - // --config / --config-file supply the whole config, so an individual config - // flag alongside one of them is a conflict, not an override. Refused rather - // than silently resolved: the three commands resolved it three different - // ways and one of them dropped --url without a word. - if err := rejectConfigJSONWithIndividualFlags(cmd, dc.config, dc.configFile); err != nil { - return err - } - // Nothing below applies on the config-JSON path: the individual flags are - // refused above, so there is nothing left for them to validate. + // --config / --config-file take precedence: buildDestinationConfigFromFlags + // returns their JSON and never looks at the individual flags. Validating + // those flags here anyway rejected commands over a value that would have + // been ignored. if dc.config != "" || dc.configFile != "" { return nil } diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go index abacc187..cb6c9dbf 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -49,8 +49,8 @@ Examples: dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API)") dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations") dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "", "Path for CLI destinations") - dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for the whole destination config; cannot be combined with the individual config flags") - dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to a JSON file holding the whole destination config; cannot be combined with the individual config flags") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") @@ -82,15 +82,10 @@ func (dc *destinationUpsertCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - // --config / --config-file supply the whole config, so an individual config - // flag alongside one of them is a conflict, not an override. Refused rather - // than silently resolved: the three commands resolved it three different - // ways and one of them dropped --url without a word. - if err := rejectConfigJSONWithIndividualFlags(cmd, dc.config, dc.configFile); err != nil { - return err - } - // Nothing below applies on the config-JSON path: the individual flags are - // refused above, so there is nothing left for them to validate. + // --config / --config-file take precedence: buildDestinationConfigFromFlags + // returns their JSON and never looks at the individual flags. Validating + // those flags here anyway rejected commands over a value that would have + // been ignored. if dc.config != "" || dc.configFile != "" { return nil } @@ -193,14 +188,18 @@ func (dc *destinationUpsertCmd) buildUpsertRequest(ctx context.Context, client * return nil, err } - // No overlay here. It existed to put --url and --cli-path back on top of a - // --config body, and only fired when --type was passed, because - // resolveDestinationType returns early on the --config path: that is how - // `upsert --config '{"url":...}' --url ...` exited 0 having sent the old - // URL while the same flags with --type HTTP sent the new one, and `update` - // disagreed with both. The combination is now refused in validateFlags, so - // every config field arrives through the builder above, once. + // Overlay for the --config path, where the config JSON was returned verbatim + // and the individual flag still has to win. rt := strings.ToUpper(resolvedType) + if config == nil { + config = make(map[string]interface{}) + } + if rt == "HTTP" && dc.url != "" { + config["url"] = dc.url + } + if rt == "CLI" { + applyCLIPath(config, dc.cliPath, false) + } req := &hookdeck.DestinationCreateRequest{ Name: dc.name,