diff --git a/REFERENCE.md b/REFERENCE.md index 80486faf..4c1ba27f 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 @@ -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:** @@ -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 @@ -1920,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:** @@ -1929,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). @@ -1951,6 +1979,19 @@ 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/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/cmd/connection_create.go b/pkg/cmd/connection_create.go index ac84c63e..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: @@ -620,6 +626,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/connection_upsert.go b/pkg/cmd/connection_upsert.go index 4b156461..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, }) @@ -468,14 +495,25 @@ 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() 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 @@ -611,7 +649,11 @@ 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) + preserveDeliveryGroupOverrides(destConfig, existingDest.Config) // Apply authentication config if provided if cu.DestinationAuthMethod != "" { 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/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_cli_type_test.go b/pkg/cmd/destination_cli_type_test.go new file mode 100644 index 00000000..a1147adf --- /dev/null +++ b/pkg/cmd/destination_cli_type_test.go @@ -0,0 +1,224 @@ +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" +) + +// 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"]) +} + +// 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"]) + }) +} + +// 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_common.go b/pkg/cmd/destination_common.go index 47a7eff7..54181454 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. @@ -134,6 +137,300 @@ 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 +// 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"] = "/" + } +} + +// 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 + } +} + +// 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 != "" +} + +// 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). +// +// 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 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 resolve the type its config flags apply to: %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. destType must be the resolved type, not the raw --type flag +// — see resolveDestinationType. +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) +} + +// 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 @@ -235,8 +532,17 @@ 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) + // 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 != "" { @@ -260,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 8bf53d47..d0028a3f 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -99,17 +99,23 @@ 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() - - // Sync url/cliPath into flags for buildDestinationConfigFromIndividualFlags when not using --config +// 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 + // 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 { - return err + return nil, err } // For HTTP/CLI, ensure url/path in config when using individual flags @@ -121,11 +127,7 @@ func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args config["url"] = dc.url } if t == "CLI" { - path := dc.cliPath - if path == "" { - path = "/" - } - config["path"] = path + applyCLIPath(config, cliPathFromFlags(cmd, dc.cliPath), true) } req := &hookdeck.DestinationCreateRequest{ @@ -138,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/destination_stored_state_test.go b/pkg/cmd/destination_stored_state_test.go new file mode 100644 index 00000000..0f97447e --- /dev/null +++ b/pkg/cmd/destination_stored_state_test.go @@ -0,0 +1,762 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/spf13/cobra" + + "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"} +} + +// 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 := 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 := resolveDestinationType("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("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) + assert.Equal(t, "", got) + }) + + t.Run("--config takes precedence so the individual flags are ignored", func(t *testing.T) { + got, err := resolveDestinationType("", 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 := resolveDestinationType("", 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 := resolveDestinationType("", 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 := resolveDestinationType("", 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 := resolveDestinationType("", 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 := resolveDestinationType("", 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"]) + }) +} + +// 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, "", 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) { + 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, "", 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) { + 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 21d27b66..4312c54b 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,79 @@ 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, 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 + 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 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, + lookupExisting, + ) + if err != nil { + return nil, err + } + + 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 + } + // 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 dafe0770..cb6c9dbf 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -96,53 +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" && dc.cliPath != "" { - config["path"] = dc.cliPath - } - - 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. - if req.Config == nil || len(req.Config) == 0 { - 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 dc.dryRun { params := map[string]string{"name": dc.name} existing, err := client.ListDestinations(ctx, params) @@ -179,3 +137,94 @@ 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 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 + 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 + } + + // --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, + lookupExisting, + ) + if err != nil { + return nil, err + } + + 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 + } + + // 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, + } + if dc.description != "" { + req.Description = &dc.description + } + // 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 { + 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/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/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..871b7b5b 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)") @@ -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. @@ -104,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 7198a932..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,10 +19,10 @@ 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) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters, hookdeck.AttemptMetricsDimensions, hookdeck.AttemptStatusValues) return c } @@ -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..7510f793 --- /dev/null +++ b/pkg/cmd/metrics_dimensions_test.go @@ -0,0 +1,226 @@ +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" +) + +// 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") +} + +// 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 a295305e..8e6a8482 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 = "connection_id, source_id, destination_id, delivery_group, issue_id" +var metricsEventsDimensions = hookdeck.EventMetricsDimensions type metricsEventsCmd struct { cmd *cobra.Command @@ -29,31 +28,18 @@ Requires --start and --end. When querying per-issue (e.g. --dimensions issue_id), --issue-id is required. -Measures: ` + metricsEventsMeasures + `. +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: ` + hookdeck.EventMetricsMeasures + `. 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 } -// 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 { @@ -67,18 +53,46 @@ 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 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 + } + // 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, hookdeck.EventRouteQueueDepth); 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 = hookdeck.TranslateQueueDepthMeasures(params.Measures) + return client.QueryQueueDepth(ctx, queueParams) } - // 2. If measures include "pending" with granularity → QueryEventsPendingTimeseries + // 2. Measures naming the pending route → QueryEventsPendingTimeseries. // API expects measures[]=count; "pending" is only used for routing. - if hasMeasure(params, map[string]bool{"pending": true}) && params.Granularity != "" { - if err := rejectUnsupportedFilters(params, hookdeck.PendingTimeseriesRouteFilters, "pending event metrics (--measures pending)"); err != nil { + // 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 measureRoute == hookdeck.EventRoutePending { + if err := rejectUnsupportedFilters(params, hookdeck.PendingTimeseriesRouteFilters, hookdeck.EventRoutePending); err != nil { + return nil, err + } + if err := rejectUnsupportedDimensions(params, hookdeck.PendingTimeseriesRouteDimensions, hookdeck.EventRoutePending); err != nil { return nil, err } pendingParams := params @@ -91,13 +105,21 @@ 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, hookdeck.EventRouteByIssue); err != nil { return nil, err } return client.QueryEventsByIssue(ctx, params) } // 4. Default → QueryEventMetrics - if err := rejectUnsupportedFilters(params, hookdeck.DefaultEventRouteFilters, "event metrics"); err != nil { + // 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, 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 new file mode 100644 index 00000000..370c5afc --- /dev/null +++ b/pkg/cmd/metrics_events_routing_test.go @@ -0,0 +1,292 @@ +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" +) + +// 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[]"]) +} + +// TestPendingStillRoutesWithGranularity guards the case that already worked. +func TestPendingStillRoutesWithGranularity(t *testing.T) { + client, path, _ := routeCapture(t) + + _, 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)) +} + +// 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") + }) +} + +// 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") + }) +} + +// 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/metrics_requests.go b/pkg/cmd/metrics_requests.go index 4b65ea7a..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,10 +19,10 @@ 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) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.RequestMetricsFilters, hookdeck.RequestMetricsDimensions, hookdeck.RequestStatusValues) return c } @@ -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 58c498e3..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) + 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/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 07dcc5bb..77227f49 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", "", 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") + 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 @@ -51,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() @@ -61,9 +123,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 status != "" { + params["status"] = 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/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/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) diff --git a/pkg/gateway/mcp/tool_actions.go b/pkg/gateway/mcp/tool_actions.go new file mode 100644 index 00000000..b317128d --- /dev/null +++ b/pkg/gateway/mcp/tool_actions.go @@ -0,0 +1,197 @@ +package mcp + +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. 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. 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 `. + // + // 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", + "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", "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"}, + } + + // 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, ", ") +} + +// 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 new file mode 100644 index 00000000..5fd0994c --- /dev/null +++ b/pkg/gateway/mcp/tool_actions_test.go @@ -0,0 +1,246 @@ +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"}, + // 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"}, + } + + 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..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" @@ -21,8 +22,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) @@ -34,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 @@ -42,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")) @@ -97,4 +136,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..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) @@ -311,14 +330,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 6f0b87d8..032234ad 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,18 +110,43 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp return ErrorResult(err.Error()), 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 + } + // 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, hookdeck.EventRouteQueueDepth); 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 + queueParams.Measures = hookdeck.TranslateQueueDepthMeasures(params.Measures) + result, err = client.QueryQueueDepth(ctx, queueParams) + case measureRoute == hookdeck.EventRoutePending: + if err := rejectFilters(params, hookdeck.PendingTimeseriesRouteFilters, hookdeck.EventRoutePending); err != nil { return ErrorResult(err.Error()), nil } - result, err = client.QueryQueueDepth(ctx, params) - case containsAny(params.Measures, "pending") && params.Granularity != "": - if err := rejectFilters(params, hookdeck.PendingTimeseriesRouteFilters, "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 @@ -126,12 +159,21 @@ 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, hookdeck.EventRouteByIssue); err != nil { return ErrorResult(err.Error()), nil } result, err = client.QueryEventsByIssue(ctx, params) default: - if err := rejectFilters(params, hookdeck.DefaultEventRouteFilters, "event metrics"); err != 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, hookdeck.EventRouteDefault); err != nil { return ErrorResult(err.Error()), nil } result, err = client.QueryEventMetrics(ctx, params) @@ -151,6 +193,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 @@ -166,6 +211,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 @@ -181,6 +229,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..ad05e059 --- /dev/null +++ b/pkg/gateway/mcp/tool_metrics_dimensions_test.go @@ -0,0 +1,189 @@ +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 + // 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] + { + 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: "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", + 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, + }) + + 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) + 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_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 new file mode 100644 index 00000000..e5550156 --- /dev/null +++ b/pkg/gateway/mcp/tool_metrics_measures_test.go @@ -0,0 +1,210 @@ +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) +} + +// 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") +} + +// 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/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..406a61ae 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) @@ -41,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")) @@ -107,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 @@ -124,10 +167,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/tool_requests_events_filters_test.go b/pkg/gateway/mcp/tool_requests_events_filters_test.go new file mode 100644 index 00000000..0ee131c6 --- /dev/null +++ b/pkg/gateway/mcp/tool_requests_events_filters_test.go @@ -0,0 +1,224 @@ +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") +} + +// 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/gateway/mcp/tools.go b/pkg/gateway/mcp/tools.go index 1deb4bc1..bffe5728 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,84 @@ 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, 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 +// 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 (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}, + "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"` @@ -237,12 +266,59 @@ 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 +// 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..aab40982 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -310,6 +310,65 @@ 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. +// +// "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"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + if payload.Message != "" { + return payload.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 { @@ -328,10 +387,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..b14374ed --- /dev/null +++ b/pkg/hookdeck/client_error_message_test.go @@ -0,0 +1,105 @@ +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", + }, + { + // 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}`, + 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..1901849d --- /dev/null +++ b/pkg/hookdeck/metrics_dimensions_test.go @@ -0,0 +1,254 @@ +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 + excludes []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, + }, + { + // 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 { + 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) + } + for _, unwanted := range tt.excludes { + assert.NotContains(t, err.Error(), unwanted, + "the error must not name a token the caller never typed") + } + }) + } +} + +// 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) + } +} + +// 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 eccb9430..6878a8f2 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. // @@ -85,3 +88,339 @@ var MCPFilterNames = MetricsFilterNames{ IssueID: "issue_id", DeliveryGroup: "delivery_group", } + +// 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. 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 = ValueList(EventStatusValueList) + 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 { + 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 { + 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. +// +// 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 +// 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) { + // 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, DimensionName(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. +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 +} + +// 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" + EventRouteByIssue = "per-issue 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, + "max_count_per_second": EventRouteDefault, + + "queue_depth": EventRouteQueueDepth, + "max_depth": EventRouteQueueDepth, + "max_age": EventRouteQueueDepth, + + "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, +} + +// 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. +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. +// +// 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 +} + +// 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 +} 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/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) + }) + } +} 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..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. @@ -274,15 +282,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 } } @@ -292,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.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..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 @@ -50,6 +63,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()) @@ -58,6 +76,7 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { teaProgram: program, teaModel: &model, doneCh: make(chan struct{}), + sendMsg: program.Send, } // Start TUI in background @@ -81,28 +100,38 @@ 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 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.sendMsg == nil { + return + } + r.send(tui.ConnectionFailedMsg{Err: err}) + time.Sleep(failedStateLinger) } // OnEventPending is called when an event starts (after 100ms delay) @@ -139,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) @@ -181,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 @@ -210,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 @@ -228,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/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..21c8c9a7 --- /dev/null +++ b/pkg/listen/tui/styles_test.go @@ -0,0 +1,115 @@ +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") + // 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/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/pkg/login/client_login.go b/pkg/login/client_login.go index 82e56d14..821d3ada 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -37,6 +37,35 @@ 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", +) + +// 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 +// be blocked. +func browserSignInNeedsStdin() bool { + return !isSSH() && canOpenBrowser() +} + const guestUpgradePollInterval = 2 * time.Second const guestUpgradeMaxAttempts = 2 * 60 @@ -57,10 +86,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 +123,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,17 +158,23 @@ 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) + // 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) @@ -257,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") @@ -267,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 924da9fa..91eda2a9 100644 --- a/pkg/login/client_login_test.go +++ b/pkg/login/client_login_test.go @@ -2,12 +2,14 @@ package login import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -134,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 } @@ -456,3 +465,473 @@ 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 }) + + clearSSHEnv(t) + + 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 }) + + clearSSHEnv(t) + + 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") + // #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 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/listen_tty_test.go b/test/acceptance/listen_tty_test.go new file mode 100644 index 00000000..8d478ba3 --- /dev/null +++ b/test/acceptance/listen_tty_test.go @@ -0,0 +1,212 @@ +//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 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") +} + +// 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)") +} 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) {