diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 4330d055..7779fb04 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -25,6 +25,16 @@ jobs: env: ACCEPTANCE_SLICE: ${{ matrix.slice }} HOOKDECK_CLI_TESTING_API_KEY: ${{ secrets[matrix.api_key_secret] }} + # Account-wide CLI key, used only by the project list/use tests in slice 0. + # It has to be account-wide: the project-scoped key `hookdeck ci` issues + # gets a 403 from GET /projects, so those tests skip themselves without it. + # + # It must also belong to the test-only account, not to a person. Such a key + # reaches every org its owner belongs to, and this repository is public, so + # anything a failing test prints is world-readable. The tests are written + # not to echo a project listing, but the account is the real control: + # scoped to its own org, a disclosure is worth nothing. + HOOKDECK_CLI_TESTING_CLI_KEY: ${{ secrets.HOOKDECK_CLI_TESTING_CLI_KEY }} HOOKDECK_CLI_TELEMETRY_DISABLED: "1" steps: - name: Check out code diff --git a/AGENTS.md b/AGENTS.md index 7231e5a5..8874d21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This repository contains the Hookdeck CLI, a Go-based command-line tool for mana - `REFERENCE.md` - Complete CLI documentation and examples ### Key Files -- `https://api.hookdeck.com/2025-07-01/openapi` - API specification (source of truth for all API interactions) +- `https://api.hookdeck.com/2026-09-01/openapi` - API specification (source of truth for all API interactions) - `pkg/cmd/sources/` - Fetches and caches the OpenAPI spec for source type enum and auth rules; use for validation and help in source and connection management - `pkg/cmd/helptext.go` - Shared Short/Long help for resource commands (sources, connections); use when adding or editing command help to avoid duplication - `.plans/` - Implementation plans and architectural decisions diff --git a/README.md b/README.md index 9feb97b2..c2eec07f 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,19 @@ $ hookdeck listen 3000 stripe --cli-key $ hookdeck listen 3000 stripe --api-key ``` -Both flags are global, so they work with any command. A **CLI key** is tied to your user account and can navigate across projects; a **project API key** is scoped to a single project. Within the CLI both are stored and used the same way (see [Credential Types](#security-config-files-and-source-control)). +Which key you hold decides what the CLI can do. The supported ways to supply one are `hookdeck login` for a CLI key, `hookdeck login --cli-key` to paste an existing one, and `hookdeck ci --api-key` for a project API key: + +| Key | Where it comes from | Reach | `project list` / `project use` | +| --- | --- | --- | --- | +| **CLI key** | `hookdeck login`, or `hookdeck login --cli-key` | every project in every organization you belong to | yes | +| **Project API key** | dashboard, or `hookdeck ci --api-key` | the one project it belongs to | no | +| **Organization API key** | dashboard | its organization's projects, given the `projects.read` scope | no | + +Only a CLI key can list or switch projects. The others are scoped below the level that question is asked at, so `hookdeck project list` answers `this credential is scoped to a single project`, and you need `hookdeck login` for account-wide access. + +`hookdeck ci --api-key` takes a **project** API key specifically. It exchanges it for a project-scoped CLI key, which is why keys minted that way cannot list projects either. An organization API key is rejected by `hookdeck ci` and by every other CLI sign-in path, so it cannot be used to authenticate the CLI at all — use it against the REST API directly. + +See also [Credential Types](#security-config-files-and-source-control) for how each is stored. The Event Gateway routes events received for a given `source` (e.g. Shopify, GitHub) to a `destination` via a `connection`. `hookdeck listen` is a standalone command that works with whichever product you're authenticated with — Hookdeck Console or the Event Gateway — receiving events for a given connection and forwarding them to your localhost at the specified port or any valid URL. @@ -511,7 +523,7 @@ To install completions permanently, redirect the output to your shell's completi ### Running in CI -If you want to use Hookdeck in CI for tests or any other purposes, authenticate with a Project API key from the dashboard. The `ci` command exchanges it for a CLI client key stored in your config. +If you want to use Hookdeck in CI for tests or any other purposes, authenticate with a Project API key from the dashboard. The `ci` command exchanges it for a CLI client key stored in your config. It must be a *project* key: organization API keys are rejected, and the resulting CLI key is project-scoped, so it cannot list or switch projects. ```sh $ hookdeck ci --api-key $HOOKDECK_API_KEY @@ -1040,6 +1052,28 @@ $ hookdeck gateway connection create \ --destination-rate-limit-period minute ``` +#### Configure delivery groups + +Isolate delivery queues by a payload field and optionally give selected groups a different maximum rate: + +```sh +$ hookdeck gateway connection create \ + --name "tenant-aware-delivery" \ + --source-name "events" \ + --source-type HTTP \ + --destination-name "tenant-aware-api" \ + --destination-type HTTP \ + --destination-url "https://api.example.com/endpoint" \ + --destination-rate-limit 100 \ + --destination-rate-limit-period second \ + --destination-delivery-group-key body.customer_id \ + --destination-delivery-group-rate 5 \ + --destination-delivery-group-rate-period second \ + --destination-delivery-group-overrides '{"cus_priority":{"rate":50,"rate_period":"second"}}' +``` + +Use `--config` or `--config-file` when you need to set `delivery_policy.groups` directly, including setting `groups` to `null` to disable grouping. + #### Upsert connections Create or update connections idempotently based on connection name - perfect for CI/CD and infrastructure-as-code workflows: @@ -1190,7 +1224,7 @@ The Hookdeck CLI configuration file is stored in TOML format and typically inclu ```toml api_key = "api_key_xxxxxxxxxxxxxxxxxxxx" project_id = "tm_xxxxxxxxxxxxxxx" -project_mode = "inbound" | "console" +project_type = "Gateway" | "Outpost" | "Console" ``` ### Local Configuration @@ -1221,12 +1255,12 @@ profile = "dev" [dev] api_key = "api_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" project_id = "tm_5JxTelcYxOJy" - project_mode = "inbound" + project_type = "Gateway" [prod] api_key = "api_key_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" project_id = "tm_U9Zod13qtsHp" - project_mode = "inbound" + project_type = "Gateway" ``` This allows you to run commands against different projects. For example, to listen to the `webhooks` source in the `dev` profile, run: diff --git a/REFERENCE.md b/REFERENCE.md index 0ea9fcf0..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 @@ -355,6 +355,10 @@ hookdeck gateway connection create [flags] | `--destination-cli-path` | `string` | CLI path for CLI destinations (default: /) (default "/") | | `--destination-custom-signature-key` | `string` | Key/header name for custom signature | | `--destination-custom-signature-secret` | `string` | Signing secret for custom signature | +| `--destination-delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | +| `--destination-delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides | +| `--destination-delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") | +| `--destination-delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) | | `--destination-description` | `string` | Destination description | | `--destination-gcp-scope` | `string` | GCP scope for service account authentication | | `--destination-gcp-service-account-key` | `string` | GCP service account key JSON for destination authentication | @@ -610,6 +614,10 @@ hookdeck gateway connection upsert [flags] | `--destination-cli-path` | `string` | CLI path for CLI destinations (default: / for new connections) | | `--destination-custom-signature-key` | `string` | Key/header name for custom signature | | `--destination-custom-signature-secret` | `string` | Signing secret for custom signature | +| `--destination-delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | +| `--destination-delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides | +| `--destination-delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") | +| `--destination-delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) | | `--destination-description` | `string` | Destination description | | `--destination-gcp-scope` | `string` | GCP scope for service account authentication | | `--destination-gcp-service-account-key` | `string` | GCP service account key JSON for destination authentication | @@ -1085,6 +1093,10 @@ hookdeck gateway destination create [flags] | `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | +| `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | +| `--delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides | +| `--delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") | +| `--delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) | | `--description` | `string` | Destination description | | `--http-method` | `string` | HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE) | | `--name` | `string` | Destination name (required) | @@ -1152,6 +1164,10 @@ hookdeck gateway destination update [flags] | `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | +| `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | +| `--delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides | +| `--delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") | +| `--delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) | | `--description` | `string` | New destination description | | `--http-method` | `string` | HTTP method for HTTP destinations | | `--name` | `string` | New destination name | @@ -1216,6 +1232,10 @@ hookdeck gateway destination upsert [flags] | `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | | `--custom-signature-key` | `string` | Key/header name for custom signature | | `--custom-signature-secret` | `string` | Signing secret for custom signature | +| `--delivery-group-key` | `string` | Payload field path used to group deliveries (for example body.customer_id) | +| `--delivery-group-overrides` | `string` | JSON object of group-specific delivery rate overrides | +| `--delivery-group-rate` | `int` | Default maximum delivery rate for each delivery group (default "0") | +| `--delivery-group-rate-period` | `string` | Delivery group rate period (second, minute, hour) | | `--description` | `string` | Destination description | | `--dry-run` | `bool` | Preview changes without applying | | `--http-method` | `string` | HTTP method for HTTP destinations | @@ -1574,6 +1594,7 @@ hookdeck gateway event list [flags] | `--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 | @@ -1725,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:** @@ -1781,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 @@ -1791,15 +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 @@ -1898,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:** @@ -1907,12 +1957,40 @@ 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`, `--source-id`, `--destination-id`, `--connection-id`, `--status`, `--output` (json). +**Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--output` (json). + +**Filter flags differ per subcommand**, because each metrics endpoint accepts a different set. A filter is only offered where the endpoint honours it: + +| Filter | events | requests | attempts | transformations | +| --- | --- | --- | --- | --- | +| `--source-id` | yes | yes | — | — | +| `--destination-id` | yes | — | yes | — | +| `--connection-id` | yes | — | — | yes | +| `--status` | yes | yes | yes | — | +| `--issue-id` | yes | — | — | yes | +| `--delivery-group` | yes | — | yes | — | + +Passing one where it does not apply is an `unknown flag` error rather than a silently ignored filter: the API drops filters it does not recognise and answers with unfiltered totals, which would otherwise look like a filtered result. + +`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/actionable_error_test.go b/pkg/cmd/actionable_error_test.go index ede67705..b72f6e75 100644 --- a/pkg/cmd/actionable_error_test.go +++ b/pkg/cmd/actionable_error_test.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -61,3 +63,78 @@ func TestActionableErrorDoesNotCaptureOrdinaryErrors(t *testing.T) { assert.False(t, errors.As(plain, &actionable), "an unmarked error must still get Execute's generic recovery message") } + +// TestUnauthorizedServerMessage: a 401 carrying an explanation should show it +// rather than the CLI's guess, which is often wrong - a project API key is valid, +// just not accepted here. See #283. +func TestUnauthorizedServerMessage(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + { + name: "a real explanation is surfaced", + err: &hookdeck.APIError{StatusCode: 401, Message: "This credential is scoped to a single project"}, + expected: "This credential is scoped to a single project", + }, + { + name: "the bare status word adds nothing", + err: &hookdeck.APIError{StatusCode: 401, Message: "Unauthorized"}, + expected: "", + }, + { + // What checkAndPrintError produces for a non-JSON body, which is what + // these endpoints send. The first version of the helper let this + // through and printed it as the explanation. + name: "our own synthesized boilerplate is not a server message", + err: &hookdeck.APIError{StatusCode: 401, Message: "unexpected http status code: 401, raw response body: Unauthorized"}, + expected: "", + }, + { + name: "case does not matter", + err: &hookdeck.APIError{StatusCode: 401, Message: " unauthorized "}, + expected: "", + }, + { + name: "no message at all", + err: &hookdeck.APIError{StatusCode: 401}, + expected: "", + }, + { + name: "not an API error", + err: errors.New("dial tcp: connection refused"), + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, unauthorizedServerMessage(tt.err)) + }) + } +} + +// TestUnauthorizedServerMessageThroughTheRealClient drives the helper with an +// error the client genuinely produced. The hand-built cases above all passed +// while the helper was broken, because they supplied a Message the real client +// never generates for these endpoints. +func TestUnauthorizedServerMessageThroughTheRealClient(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + })) + t.Cleanup(ts.Close) + + baseURL, err := url.Parse(ts.URL) + require.NoError(t, err) + + client := &hookdeck.Client{BaseURL: baseURL, APIKey: "hk_test_key", TelemetryDisabled: true} + _, err = client.ValidateAPIKey() + require.Error(t, err) + require.True(t, hookdeck.IsUnauthorizedError(err), "should be recognized as a 401") + + assert.Empty(t, unauthorizedServerMessage(err), + "a plain-text 401 carries no explanation, so the caller must fall back to guidance") +} diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index b0bfe5be..d285db2a 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -91,6 +91,11 @@ type connectionCreateCmd struct { DestinationRateLimit int DestinationRateLimitPeriod string + DestinationDeliveryGroupKey string + DestinationDeliveryGroupRate int + DestinationDeliveryGroupRatePeriod string + DestinationDeliveryGroupOverrides string + // Rule flags shared with update/upsert connectionRuleFlags @@ -223,8 +228,7 @@ func newConnectionCreateCmd() *connectionCreateCmd { cc.cmd.Flags().StringVar(&cc.DestinationGCPScope, "destination-gcp-scope", "", "GCP scope for service account authentication") // Destination rate limiting flags - cc.cmd.Flags().IntVar(&cc.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") - cc.cmd.Flags().StringVar(&cc.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + addConnectionDestinationDeliveryPolicyFlags(cc.cmd, cc) addConnectionRuleFlags(cc.cmd, &cc.connectionRuleFlags) @@ -406,7 +410,16 @@ func (cc *connectionCreateCmd) validateRateLimiting() error { // Let API validate the period value (supports: second, minute, hour, concurrent) } - return nil + _, err := buildDeliveryPolicy( + cc.DestinationRateLimit, + cc.DestinationRateLimitPeriod, + cc.DestinationDeliveryGroupKey, + cc.DestinationDeliveryGroupRate, + cc.DestinationDeliveryGroupRatePeriod, + cc.DestinationDeliveryGroupOverrides, + "destination-", + ) + return err } func (cc *connectionCreateCmd) runConnectionCreateCmd(cmd *cobra.Command, args []string) error { @@ -564,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: @@ -595,11 +614,22 @@ func (cc *connectionCreateCmd) buildDestinationConfig() (map[string]interface{}, config["auth"] = auth } - // Add rate limiting configuration - if cc.DestinationRateLimit > 0 { - config["rate_limit"] = cc.DestinationRateLimit - config["rate_limit_period"] = cc.DestinationRateLimitPeriod + policy, err := buildDeliveryPolicy( + cc.DestinationRateLimit, + cc.DestinationRateLimitPeriod, + cc.DestinationDeliveryGroupKey, + cc.DestinationDeliveryGroupRate, + cc.DestinationDeliveryGroupRatePeriod, + cc.DestinationDeliveryGroupOverrides, + "destination-", + ) + 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 { return make(map[string]interface{}), nil diff --git a/pkg/cmd/connection_upsert.go b/pkg/cmd/connection_upsert.go index ecde969c..54f559bf 100644 --- a/pkg/cmd/connection_upsert.go +++ b/pkg/cmd/connection_upsert.go @@ -158,8 +158,7 @@ func newConnectionUpsertCmd() *connectionUpsertCmd { cu.cmd.Flags().StringVar(&cu.DestinationGCPScope, "destination-gcp-scope", "", "GCP scope for service account authentication") // Destination rate limiting flags - cu.cmd.Flags().IntVar(&cu.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") - cu.cmd.Flags().StringVar(&cu.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + addConnectionDestinationDeliveryPolicyFlags(cu.cmd, cu.connectionCreateCmd) addConnectionRuleFlags(cu.cmd, &cu.connectionCreateCmd.connectionRuleFlags) @@ -245,6 +244,8 @@ func (cu *connectionUpsertCmd) hasAnyDestinationFlag() bool { cu.destinationURL != "" || cu.destinationCliPath != "" || cu.destinationPathForwardingDisabled != nil || cu.destinationHTTPMethod != "" || cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" || + cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 || + cu.DestinationDeliveryGroupRatePeriod != "" || cu.DestinationDeliveryGroupOverrides != "" || cu.DestinationAuthMethod != "" } @@ -256,7 +257,9 @@ func (cu *connectionUpsertCmd) hasAnyRuleFlag() bool { // Helper to check if any rate limit flags are set func (cu *connectionUpsertCmd) hasAnyRateLimitFlag() bool { - return cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" + return cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" || + cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 || + cu.DestinationDeliveryGroupRatePeriod != "" || cu.DestinationDeliveryGroupOverrides != "" } // Validate source flags for consistency @@ -289,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 != "" || @@ -310,19 +311,50 @@ func (cu *connectionUpsertCmd) runConnectionUpsertCmd(cmd *cobra.Command, args [ hasDestinationConfigOnly := (cu.destinationURL != "" || cu.destinationCliPath != "" || cu.destinationPathForwardingDisabled != nil || cu.destinationHTTPMethod != "" || - cu.DestinationRateLimit != 0 || cu.DestinationAuthMethod != "") && + cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" || cu.DestinationDeliveryGroupKey != "" || + cu.DestinationDeliveryGroupRate != 0 || cu.DestinationDeliveryGroupRatePeriod != "" || + cu.DestinationDeliveryGroupOverrides != "" || cu.DestinationAuthMethod != "") && cu.destinationName == "" && cu.destinationType == "" && cu.destinationID == "" // Also need to fetch existing when name is provided without type (to fill in the type) 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, }) @@ -463,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 @@ -478,6 +521,8 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection, cu.destinationPathForwardingDisabled != nil || cu.destinationHTTPMethod != "" || cu.DestinationRateLimit != 0 || cu.DestinationRateLimitPeriod != "" || + cu.DestinationDeliveryGroupKey != "" || cu.DestinationDeliveryGroupRate != 0 || + cu.DestinationDeliveryGroupRatePeriod != "" || cu.DestinationDeliveryGroupOverrides != "" || cu.DestinationAuthMethod != "" if hasDestinationConfigUpdate { @@ -592,11 +637,23 @@ func (cu *connectionUpsertCmd) buildDestinationInputForUpdate(existingDest *hook destConfig["http_method"] = method } - // Apply rate limiting if provided - if cu.DestinationRateLimit > 0 { - destConfig["rate_limit"] = cu.DestinationRateLimit - destConfig["rate_limit_period"] = cu.DestinationRateLimitPeriod + policy, err := buildDeliveryPolicy( + cu.DestinationRateLimit, + cu.DestinationRateLimitPeriod, + cu.DestinationDeliveryGroupKey, + cu.DestinationDeliveryGroupRate, + cu.DestinationDeliveryGroupRatePeriod, + cu.DestinationDeliveryGroupOverrides, + "destination-", + ) + 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 bf28291b..8719558c 100644 --- a/pkg/cmd/connection_upsert_test.go +++ b/pkg/cmd/connection_upsert_test.go @@ -433,6 +433,56 @@ func TestUpsertValidateDestinationFlagsAllowsNameOnly(t *testing.T) { assert.NoError(t, err, "validateDestinationFlags should allow --destination-name alone for upsert") } +func TestConnectionDestinationDeliveryPolicy(t *testing.T) { + cc := &connectionCreateCmd{ + DestinationRateLimit: 100, + DestinationRateLimitPeriod: "minute", + DestinationDeliveryGroupKey: "headers.x-tenant-id", + DestinationDeliveryGroupRate: 10, + DestinationDeliveryGroupRatePeriod: "second", + DestinationDeliveryGroupOverrides: `{"priority":{"rate":50,"rate_period":"second"}}`, + } + + config, err := cc.buildDestinationConfig() + require.NoError(t, err) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, 100, policy["rate"]) + groups, ok := policy["groups"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "headers.x-tenant-id", groups["key"]) + assert.Equal(t, 10, groups["rate"]) +} + +func TestConnectionUpsertMergesDeliveryPolicy(t *testing.T) { + cu := &connectionUpsertCmd{connectionCreateCmd: &connectionCreateCmd{ + DestinationDeliveryGroupKey: "body.customer_id", + DestinationDeliveryGroupRate: 5, + DestinationDeliveryGroupRatePeriod: "second", + }} + existing := &hookdeck.Destination{ + Name: "api", + Type: "HTTP", + Config: map[string]interface{}{ + "url": "https://api.example.com", + "delivery_policy": map[string]interface{}{ + "rate": 100, + "period": "minute", + }, + }, + } + + input, err := cu.buildDestinationInputForUpdate(existing) + require.NoError(t, err) + policy, ok := input.Config["delivery_policy"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, 100, policy["rate"]) + assert.Equal(t, "minute", policy["period"]) + groups, ok := policy["groups"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "body.customer_id", groups["key"]) +} + // TestUpsertBuildRequestFillsSourceTypeFromExisting verifies that when // --source-name is provided without --source-type during an update, // the existing source type is used. @@ -470,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 049d8d02..54181454 100644 --- a/pkg/cmd/destination_common.go +++ b/pkg/cmd/destination_common.go @@ -1,10 +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. @@ -24,10 +29,45 @@ type destinationConfigFlags struct { CustomSignatureKey string RateLimit int RateLimitPeriod string + DeliveryGroupKey string + DeliveryGroupRate int + DeliveryGroupRatePeriod string + DeliveryGroupOverrides string PathForwardingDisabled *bool HTTPMethod string } +func addDestinationDeliveryPolicyFlags(cmd *cobra.Command, flags *destinationConfigFlags) { + cmd.Flags().IntVar(&flags.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") + cmd.Flags().StringVar(&flags.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + cmd.Flags().StringVar(&flags.DeliveryGroupKey, "delivery-group-key", "", "Payload field path used to group deliveries (for example body.customer_id)") + cmd.Flags().IntVar(&flags.DeliveryGroupRate, "delivery-group-rate", 0, "Default maximum delivery rate for each delivery group") + cmd.Flags().StringVar(&flags.DeliveryGroupRatePeriod, "delivery-group-rate-period", "", "Delivery group rate period (second, minute, hour)") + cmd.Flags().StringVar(&flags.DeliveryGroupOverrides, "delivery-group-overrides", "", "JSON object of group-specific delivery rate overrides") +} + +func addConnectionDestinationDeliveryPolicyFlags(cmd *cobra.Command, flags *connectionCreateCmd) { + cmd.Flags().IntVar(&flags.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") + cmd.Flags().StringVar(&flags.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + cmd.Flags().StringVar(&flags.DestinationDeliveryGroupKey, "destination-delivery-group-key", "", "Payload field path used to group deliveries (for example body.customer_id)") + cmd.Flags().IntVar(&flags.DestinationDeliveryGroupRate, "destination-delivery-group-rate", 0, "Default maximum delivery rate for each delivery group") + cmd.Flags().StringVar(&flags.DestinationDeliveryGroupRatePeriod, "destination-delivery-group-rate-period", "", "Delivery group rate period (second, minute, hour)") + cmd.Flags().StringVar(&flags.DestinationDeliveryGroupOverrides, "destination-delivery-group-overrides", "", "JSON object of group-specific delivery rate overrides") +} + +func (f *destinationConfigFlags) validateDeliveryPolicyFlags(flagPrefix string) error { + _, err := buildDeliveryPolicy( + f.RateLimit, + f.RateLimitPeriod, + f.DeliveryGroupKey, + f.DeliveryGroupRate, + f.DeliveryGroupRatePeriod, + f.DeliveryGroupOverrides, + flagPrefix, + ) + return err +} + // hasAnyDestinationConfig returns true if any individual destination config flag is set. func (f *destinationConfigFlags) hasAnyDestinationConfig() bool { if f == nil { @@ -36,7 +76,375 @@ func (f *destinationConfigFlags) hasAnyDestinationConfig() bool { return f.URL != "" || f.CliPath != "" || f.AuthMethod != "" || f.BearerToken != "" || f.BasicAuthUser != "" || f.BasicAuthPass != "" || f.APIKey != "" || f.APIKeyHeader != "" || f.CustomSignatureSecret != "" || f.CustomSignatureKey != "" || - f.RateLimit > 0 || f.RateLimitPeriod != "" || f.PathForwardingDisabled != nil || f.HTTPMethod != "" + f.RateLimit > 0 || f.RateLimitPeriod != "" || f.DeliveryGroupKey != "" || + f.DeliveryGroupRate > 0 || f.DeliveryGroupRatePeriod != "" || f.DeliveryGroupOverrides != "" || + f.PathForwardingDisabled != nil || f.HTTPMethod != "" +} + +func buildDeliveryPolicy(rate int, period, groupKey string, groupRate int, groupRatePeriod, overridesJSON, flagPrefix string) (map[string]interface{}, error) { + policy := make(map[string]interface{}) + // A negative rate is something the caller typed, so it has to be rejected + // rather than treated as absent. Testing only `rate > 0` let `--rate-limit -5` + // fall through both guards and be dropped, and the command then succeeded + // having quietly ignored the value. + if rate < 0 { + return nil, fmt.Errorf("--%srate-limit must be a positive integer", flagPrefix) + } + if period != "" && rate == 0 { + return nil, fmt.Errorf("--%srate-limit must be a positive integer when rate limiting is configured", flagPrefix) + } + if rate > 0 { + if period == "" { + return nil, fmt.Errorf("--%srate-limit-period is required when --%srate-limit is set", flagPrefix, flagPrefix) + } + policy["rate"] = rate + policy["period"] = period + } + + // Same again for the group rate: a negative value must count as configured, + // or it is silently discarded instead of refused. + hasGroups := groupKey != "" || groupRate != 0 || groupRatePeriod != "" || overridesJSON != "" + if !hasGroups { + return policy, nil + } + groupFlagPrefix := "--" + flagPrefix + "delivery-group-" + if groupKey == "" { + return nil, fmt.Errorf("%skey is required when delivery groups are configured", groupFlagPrefix) + } + if groupRate <= 0 { + return nil, fmt.Errorf("%srate must be a positive integer when delivery groups are configured", groupFlagPrefix) + } + if groupRatePeriod == "" { + return nil, fmt.Errorf("%srate-period is required when delivery groups are configured", groupFlagPrefix) + } + + groups := map[string]interface{}{ + "key": groupKey, + "rate": groupRate, + "rate_period": groupRatePeriod, + } + if overridesJSON != "" { + var overrides map[string]interface{} + if err := json.Unmarshal([]byte(overridesJSON), &overrides); err != nil { + return nil, fmt.Errorf("%soverrides must be a valid JSON object: %w", groupFlagPrefix, err) + } + if overrides == nil { + return nil, fmt.Errorf("%soverrides must be a valid JSON object", groupFlagPrefix) + } + groups["overrides"] = overrides + } + policy["groups"] = groups + 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 + } + merged := make(map[string]interface{}) + if existing, ok := config["delivery_policy"].(map[string]interface{}); ok { + for key, value := range existing { + merged[key] = value + } + } + for key, value := range policy { + merged[key] = value + } + config["delivery_policy"] = merged } // buildDestinationAuthConfig builds auth section for destination config from flags. @@ -112,12 +520,27 @@ func buildDestinationConfigFromIndividualFlags(destType string, f *destinationCo config["auth"] = auth } - if f.RateLimit > 0 { - config["rate_limit"] = f.RateLimit - if f.RateLimitPeriod == "" { - return nil, fmt.Errorf("--rate-limit-period is required when --rate-limit is set") - } - config["rate_limit_period"] = f.RateLimitPeriod + policy, err := buildDeliveryPolicy( + f.RateLimit, + f.RateLimitPeriod, + f.DeliveryGroupKey, + f.DeliveryGroupRate, + f.DeliveryGroupRatePeriod, + f.DeliveryGroupOverrides, + "", + ) + 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) { @@ -143,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_config_json_test.go b/pkg/cmd/destination_config_json_test.go index f6ecda4a..1f7e0b6e 100644 --- a/pkg/cmd/destination_config_json_test.go +++ b/pkg/cmd/destination_config_json_test.go @@ -13,15 +13,17 @@ import ( // into a config map with exact values preserved. func TestBuildDestinationConfigFromJSONString(t *testing.T) { t.Run("HTTP config JSON with exact values", func(t *testing.T) { - input := `{"url":"https://api.example.com/hooks","http_method":"PUT","rate_limit":100,"rate_limit_period":"second"}` + input := `{"url":"https://api.example.com/hooks","http_method":"PUT","delivery_policy":{"rate":100,"period":"second"}}` config, err := buildDestinationConfigFromFlags(input, "", "", nil) require.NoError(t, err) require.NotNil(t, config) assert.Equal(t, "https://api.example.com/hooks", config["url"]) assert.Equal(t, "PUT", config["http_method"]) - assert.Equal(t, float64(100), config["rate_limit"]) - assert.Equal(t, "second", config["rate_limit_period"]) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(100), policy["rate"]) + assert.Equal(t, "second", policy["period"]) }) t.Run("config with auth JSON preserves exact values", func(t *testing.T) { @@ -58,11 +60,65 @@ func TestBuildDestinationConfigFromJSONString(t *testing.T) { }) } +func TestBuildDestinationConfigFromIndividualFlagsDeliveryPolicy(t *testing.T) { + flags := &destinationConfigFlags{ + RateLimit: 100, + RateLimitPeriod: "minute", + DeliveryGroupKey: "body.customer_id", + DeliveryGroupRate: 5, + DeliveryGroupRatePeriod: "second", + DeliveryGroupOverrides: `{"cus_priority":{"rate":50,"rate_period":"second"}}`, + } + + config, err := buildDestinationConfigFromIndividualFlags("HTTP", flags) + require.NoError(t, err) + assert.NotContains(t, config, "rate_limit") + assert.NotContains(t, config, "rate_limit_period") + + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, 100, policy["rate"]) + assert.Equal(t, "minute", policy["period"]) + + groups, ok := policy["groups"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "body.customer_id", groups["key"]) + assert.Equal(t, 5, groups["rate"]) + assert.Equal(t, "second", groups["rate_period"]) + overrides, ok := groups["overrides"].(map[string]interface{}) + require.True(t, ok) + priority, ok := overrides["cus_priority"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(50), priority["rate"]) + assert.Equal(t, "second", priority["rate_period"]) +} + +func TestBuildDestinationConfigFromIndividualFlagsDeliveryGroupValidation(t *testing.T) { + tests := []struct { + name string + flags destinationConfigFlags + wantError string + }{ + {name: "missing key", flags: destinationConfigFlags{DeliveryGroupRate: 5, DeliveryGroupRatePeriod: "second"}, wantError: "--delivery-group-key"}, + {name: "missing rate", flags: destinationConfigFlags{DeliveryGroupKey: "body.customer_id", DeliveryGroupRatePeriod: "second"}, wantError: "--delivery-group-rate"}, + {name: "missing period", flags: destinationConfigFlags{DeliveryGroupKey: "body.customer_id", DeliveryGroupRate: 5}, wantError: "--delivery-group-rate-period"}, + {name: "invalid overrides", flags: destinationConfigFlags{DeliveryGroupKey: "body.customer_id", DeliveryGroupRate: 5, DeliveryGroupRatePeriod: "second", DeliveryGroupOverrides: "[]"}, wantError: "--delivery-group-overrides"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildDestinationConfigFromIndividualFlags("HTTP", &tt.flags) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + // TestBuildDestinationConfigFromJSONFile verifies that --config-file reads a JSON file // and produces a config map with exact values preserved. func TestBuildDestinationConfigFromJSONFile(t *testing.T) { t.Run("file config JSON with exact values", func(t *testing.T) { - content := `{"url":"https://file-based.example.com/hooks","http_method":"PATCH","rate_limit":50,"rate_limit_period":"minute"}` + content := `{"url":"https://file-based.example.com/hooks","http_method":"PATCH","delivery_policy":{"rate":50,"period":"minute"}}` tmpFile := filepath.Join(t.TempDir(), "dest-config.json") require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644)) @@ -72,8 +128,10 @@ func TestBuildDestinationConfigFromJSONFile(t *testing.T) { assert.Equal(t, "https://file-based.example.com/hooks", config["url"]) assert.Equal(t, "PATCH", config["http_method"]) - assert.Equal(t, float64(50), config["rate_limit"]) - assert.Equal(t, "minute", config["rate_limit_period"]) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(50), policy["rate"]) + assert.Equal(t, "minute", policy["period"]) }) t.Run("file with auth config preserves exact values", func(t *testing.T) { diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go index 185c103f..d0028a3f 100644 --- a/pkg/cmd/destination_create.go +++ b/pkg/cmd/destination_create.go @@ -62,8 +62,7 @@ Examples: dc.cmd.Flags().StringVar(&dc.APIKeyTo, "api-key-to", "header", "Where to send API key (header or query)") dc.cmd.Flags().StringVar(&dc.CustomSignatureSecret, "custom-signature-secret", "", "Signing secret for custom signature") dc.cmd.Flags().StringVar(&dc.CustomSignatureKey, "custom-signature-key", "", "Key/header name for custom signature") - dc.cmd.Flags().IntVar(&dc.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") - dc.cmd.Flags().StringVar(&dc.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + addDestinationDeliveryPolicyFlags(dc.cmd, &dc.destinationConfigFlags) dc.cmd.Flags().StringVar(&dc.HTTPMethod, "http-method", "", "HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE)") dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") @@ -90,23 +89,33 @@ func (dc *destinationCreateCmd) validateFlags(cmd *cobra.Command, args []string) if t == "HTTP" && dc.url == "" && dc.config == "" && dc.configFile == "" { return fmt.Errorf("--url is required for HTTP destinations") } - if dc.RateLimit > 0 && dc.RateLimitPeriod == "" { - return fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + // --config / --config-file take precedence: buildDestinationConfigFromFlags + // returns their JSON and never looks at the individual flags. Validating + // those flags here anyway rejected commands over a value that would have + // been ignored. + if dc.config != "" || dc.configFile != "" { + return nil } - return nil + 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 @@ -118,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{ @@ -135,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_get.go b/pkg/cmd/destination_get.go index 19ee87f6..f47cd819 100644 --- a/pkg/cmd/destination_get.go +++ b/pkg/cmd/destination_get.go @@ -17,8 +17,8 @@ import ( type destinationGetCmd struct { cmd *cobra.Command - output string - includeDestAuth bool + output string + includeDestAuth bool } func newDestinationGetCmd() *destinationGetCmd { 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 53b914ba..4312c54b 100644 --- a/pkg/cmd/destination_update.go +++ b/pkg/cmd/destination_update.go @@ -60,8 +60,7 @@ Examples: dc.cmd.Flags().StringVar(&dc.APIKeyTo, "api-key-to", "header", "Where to send API key (header or query)") dc.cmd.Flags().StringVar(&dc.CustomSignatureSecret, "custom-signature-secret", "", "Signing secret for custom signature") dc.cmd.Flags().StringVar(&dc.CustomSignatureKey, "custom-signature-key", "", "Key/header name for custom signature") - dc.cmd.Flags().IntVar(&dc.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") - dc.cmd.Flags().StringVar(&dc.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + addDestinationDeliveryPolicyFlags(dc.cmd, &dc.destinationConfigFlags) dc.cmd.Flags().StringVar(&dc.HTTPMethod, "http-method", "", "HTTP method for HTTP destinations") dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") @@ -85,10 +84,14 @@ func (dc *destinationUpdateCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - if dc.RateLimit > 0 && dc.RateLimitPeriod == "" { - return fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + // --config / --config-file take precedence: buildDestinationConfigFromFlags + // returns their JSON and never looks at the individual flags. Validating + // those flags here anyway rejected commands over a value that would have + // been ignored. + if dc.config != "" || dc.configFile != "" { + return nil } - return nil + return dc.destinationConfigFlags.validateDeliveryPolicyFlags("") } func (dc *destinationUpdateCmd) runDestinationUpdateCmd(cmd *cobra.Command, args []string) error { @@ -96,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 { @@ -141,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 2cc93f82..cb6c9dbf 100644 --- a/pkg/cmd/destination_upsert.go +++ b/pkg/cmd/destination_upsert.go @@ -60,8 +60,7 @@ Examples: dc.cmd.Flags().StringVar(&dc.APIKeyTo, "api-key-to", "header", "Where to send API key (header or query)") dc.cmd.Flags().StringVar(&dc.CustomSignatureSecret, "custom-signature-secret", "", "Signing secret for custom signature") dc.cmd.Flags().StringVar(&dc.CustomSignatureKey, "custom-signature-key", "", "Key/header name for custom signature") - dc.cmd.Flags().IntVar(&dc.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") - dc.cmd.Flags().StringVar(&dc.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + addDestinationDeliveryPolicyFlags(dc.cmd, &dc.destinationConfigFlags) dc.cmd.Flags().StringVar(&dc.HTTPMethod, "http-method", "", "HTTP method for HTTP destinations") dc.cmd.Flags().BoolVar(&dc.dryRun, "dry-run", false, "Preview changes without applying") dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") @@ -83,63 +82,25 @@ func (dc *destinationUpsertCmd) validateFlags(cmd *cobra.Command, args []string) if dc.config != "" && dc.configFile != "" { return fmt.Errorf("cannot use both --config and --config-file") } - if dc.RateLimit > 0 && dc.RateLimitPeriod == "" { - return fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + // --config / --config-file take precedence: buildDestinationConfigFromFlags + // returns their JSON and never looks at the individual flags. Validating + // those flags here anyway rejected commands over a value that would have + // been ignored. + if dc.config != "" || dc.configFile != "" { + return nil } - return nil + return dc.destinationConfigFlags.validateDeliveryPolicyFlags("") } func (dc *destinationUpsertCmd) runDestinationUpsertCmd(cmd *cobra.Command, args []string) error { 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) @@ -176,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 6d4c8c0e..15f8e8b2 100644 --- a/pkg/cmd/event_list.go +++ b/pkg/cmd/event_list.go @@ -15,32 +15,34 @@ 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 } func newEventListCmd() *eventListCmd { @@ -63,7 +65,8 @@ Examples: ec.cmd.Flags().StringVar(&ec.connectionID, "connection-id", "", "Filter by connection ID") 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.status, "status", "", "Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED)") + ec.cmd.Flags().StringVar(&ec.deliveryGroup, "delivery-group", "", "Filter by delivery group") + 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") @@ -94,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 != "" { @@ -108,8 +118,11 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error if ec.destinationID != "" { params["destination_id"] = ec.destinationID } - if ec.status != "" { - params["status"] = ec.status + if ec.deliveryGroup != "" { + params["delivery_group"] = ec.deliveryGroup + } + 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 1269e1c1..0b8738cc 100644 --- a/pkg/cmd/gateway.go +++ b/pkg/cmd/gateway.go @@ -46,10 +46,7 @@ func requireGatewayProject(cfg *config.Config) error { if cfg.Profile.ProjectId == "" { return fmt.Errorf("no project selected. Run 'hookdeck project use' to select a project") } - projectType := cfg.Profile.ProjectType - if projectType == "" && cfg.Profile.ProjectMode != "" { - projectType = config.ModeToProjectType(cfg.Profile.ProjectMode) - } + projectType := cfg.Profile.ResolveProjectType() if projectType == "" { // Resolve team/project/mode/type from API (authoritative for the key). Do not clear // guest_url here — gateway PreRun may run for users who still have a guest upgrade link. @@ -62,7 +59,13 @@ func requireGatewayProject(cfg *config.Config) error { _ = cfg.Profile.SaveProfile() } if !config.IsGatewayProject(projectType) { - return fmt.Errorf("this command requires a Gateway project; current project type is %s. Use 'hookdeck project use' to switch to a Gateway project", projectType) + // Show the label, not the wire value: "Outpost" is what the user saw in + // the project picker and in `project list`. + shown := config.TypeLabel(projectType) + if shown == "" { + shown = projectType + } + return fmt.Errorf("this command requires a Gateway project; current project type is %s. Use 'hookdeck project use' to switch to a Gateway project", shown) } return nil } @@ -85,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/gateway_test.go b/pkg/cmd/gateway_test.go index 56643c00..dfd85af0 100644 --- a/pkg/cmd/gateway_test.go +++ b/pkg/cmd/gateway_test.go @@ -19,7 +19,7 @@ func TestRequireGatewayProject(t *testing.T) { t.Run("no API key", func(t *testing.T) { cfg := &config.Config{} cfg.Profile.ProjectId = "proj_1" - cfg.Profile.ProjectType = config.ProjectTypeGateway + cfg.Profile.ProjectType = config.ProjectTypeEventGateway err := requireGatewayProject(cfg) require.Error(t, err) assert.Contains(t, err.Error(), "authenticated") @@ -38,7 +38,7 @@ func TestRequireGatewayProject(t *testing.T) { cfg := &config.Config{} cfg.Profile.APIKey = "sk_xxx" cfg.Profile.ProjectId = "proj_1" - cfg.Profile.ProjectType = config.ProjectTypeGateway + cfg.Profile.ProjectType = config.ProjectTypeEventGateway err := requireGatewayProject(cfg) assert.NoError(t, err) }) @@ -105,7 +105,7 @@ func TestRequireGatewayProject_resolveFromValidate(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "team_from_validate", - ProjectMode: "inbound", + ProjectType: "event_gateway", }) })) t.Cleanup(server.Close) @@ -128,7 +128,7 @@ guest_url = "https://guest.example/keep-me" err = requireGatewayProject(cfg) require.NoError(t, err) require.Equal(t, "team_from_validate", cfg.Profile.ProjectId) - require.Equal(t, config.ProjectTypeGateway, cfg.Profile.ProjectType) + require.Equal(t, config.ProjectTypeEventGateway, cfg.Profile.ProjectType) require.Equal(t, "inbound", cfg.Profile.ProjectMode) require.Equal(t, "https://guest.example/keep-me", cfg.Profile.GuestURL, "gateway validate path must not clear guest_url") } diff --git a/pkg/cmd/listen_cli_key_test.go b/pkg/cmd/listen_cli_key_test.go index b5c6aa26..ea7396c8 100644 --- a/pkg/cmd/listen_cli_key_test.go +++ b/pkg/cmd/listen_cli_key_test.go @@ -26,7 +26,7 @@ func validateStub(t *testing.T, projectID, projectName string) *httptest.Server w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: projectID, - ProjectMode: "console", + ProjectType: "console", ProjectName: projectName, }) })) diff --git a/pkg/cmd/login.go b/pkg/cmd/login.go index fc615110..e866a015 100644 --- a/pkg/cmd/login.go +++ b/pkg/cmd/login.go @@ -91,7 +91,13 @@ func (lc *loginCmd) runLoginCmd(cmd *cobra.Command, args []string) error { // saveLocalConfig writes the current profile credentials to .hookdeck/config.toml // and prints a security warning if the file is newly created. func saveLocalConfig() error { - isNewConfig, err := Config.UseProjectLocal(Config.Profile.ProjectId, Config.Profile.ProjectMode) + // Fall back to the raw mode when the type is unrecognized, so a value this + // CLI does not understand is carried through rather than dropped. + projectType := Config.Profile.ResolveProjectType() + if projectType == "" { + projectType = Config.Profile.ProjectMode + } + isNewConfig, err := Config.UseProjectLocal(Config.Profile.ProjectId, projectType) if err != nil { return err } diff --git a/pkg/cmd/metrics.go b/pkg/cmd/metrics.go index db2da812..871b7b5b 100644 --- a/pkg/cmd/metrics.go +++ b/pkg/cmd/metrics.go @@ -61,35 +61,56 @@ type metricsCommonFlags struct { dimensions string sourceID string destinationID string + deliveryGroup string connectionID string status string issueID string output string } -// addMetricsCommonFlags adds common metrics flags to cmd and binds them to f. -// For subcommands that take a required resource id as an argument (e.g. events-by-issue ), -// pass skipIssueID true so --issue-id is not added as a flag. -func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags) { - addMetricsCommonFlagsEx(cmd, f, false) -} - -func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, skipIssueID bool) { +// 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, 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, status)") - cmd.Flags().StringVar(&f.sourceID, "source-id", "", "Filter by source ID") - cmd.Flags().StringVar(&f.destinationID, "destination-id", "", "Filter by destination ID") - cmd.Flags().StringVar(&f.connectionID, "connection-id", "", "Filter by connection ID") - cmd.Flags().StringVar(&f.status, "status", "", "Filter by status (e.g. SUCCESSFUL, FAILED)") - if !skipIssueID { + 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") + } + if filters.DestinationID { + cmd.Flags().StringVar(&f.destinationID, "destination-id", "", "Filter by destination ID") + } + if filters.DeliveryGroup { + cmd.Flags().StringVar(&f.deliveryGroup, "delivery-group", "", "Filter by delivery group") + } + if filters.ConnectionID { + cmd.Flags().StringVar(&f.connectionID, "connection-id", "", "Filter by connection ID") + } + if filters.Status { + 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)") } 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. +func rejectUnsupportedFilters(params hookdeck.MetricsQueryParams, allowed hookdeck.MetricsFilters, route string) error { + 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. @@ -122,6 +143,7 @@ func metricsParamsFromFlags(f *metricsCommonFlags) hookdeck.MetricsQueryParams { Dimensions: dimensions, SourceID: f.sourceID, DestinationID: f.destinationID, + DeliveryGroup: f.deliveryGroup, ConnectionID: f.connectionID, Status: f.status, IssueID: f.issueID, diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go index 96447446..8c7b19f9 100644 --- a/pkg/cmd/metrics_attempts.go +++ b/pkg/cmd/metrics_attempts.go @@ -3,14 +3,13 @@ package cmd import ( "context" "fmt" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "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 + cmd *cobra.Command flags metricsCommonFlags } @@ -20,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) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.AttemptMetricsFilters, hookdeck.AttemptMetricsDimensions, hookdeck.AttemptStatusValues) return c } @@ -32,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 7cb691c6..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, 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) + 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,14 +53,48 @@ 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) { - return client.QueryQueueDepth(ctx, params) + // 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 + } + // 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 != "" { + // 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 pendingParams.Measures = []string{"count"} return client.QueryEventsPendingTimeseries(ctx, pendingParams) @@ -85,9 +105,23 @@ 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, 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 + // 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_filters_test.go b/pkg/cmd/metrics_filters_test.go new file mode 100644 index 00000000..793d2b86 --- /dev/null +++ b/pkg/cmd/metrics_filters_test.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestMetricsFlagsMatchTheEndpointSchemas guards against offering a filter the +// endpoint ignores, which returns unfiltered totals that look filtered. +// +// Expectations are hardcoded, so an API-side change will NOT fail this test; it +// catches the CLI and MCP layers drifting apart. Re-check the matrix against +// https://api.hookdeck.com/2026-09-01/openapi when the version moves. +func TestMetricsFlagsMatchTheEndpointSchemas(t *testing.T) { + all := []string{"source-id", "destination-id", "connection-id", "status", "issue-id", "delivery-group"} + + tests := []struct { + name string + cmd *cobra.Command + offered []string + }{ + {"requests", newMetricsRequestsCmd().cmd, []string{"source-id", "status"}}, + {"attempts", newMetricsAttemptsCmd().cmd, []string{"destination-id", "status", "delivery-group"}}, + {"transformations", newMetricsTransformationsCmd().cmd, []string{"connection-id", "issue-id"}}, + // events fans out over four endpoints, so it offers the union and + // validates per route at run time. + {"events", newMetricsEventsCmd().cmd, all}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offered := map[string]bool{} + for _, f := range tt.offered { + offered[f] = true + } + for _, flag := range all { + got := tt.cmd.Flags().Lookup(flag) != nil + if offered[flag] { + assert.True(t, got, "%s honours %s and should offer the flag", tt.name, flag) + } else { + assert.False(t, got, "%s ignores %s; offering it would imply a filter that does nothing", tt.name, flag) + } + } + }) + } +} + +// TestEventMetricsRejectFiltersTheRouteIgnores covers the routes `metrics events` +// can take. The flag set cannot be decided when the command is built, so each +// route has to refuse the filters its endpoint would drop. +func TestEventMetricsRejectFiltersTheRouteIgnores(t *testing.T) { + tests := []struct { + name string + params hookdeck.MetricsQueryParams + wantErr string + }{ + { + name: "queue depth ignores source", + params: hookdeck.MetricsQueryParams{Measures: []string{"queue_depth"}, SourceID: "src_1"}, + wantErr: "--source-id", + }, + { + name: "pending timeseries ignores delivery group", + params: hookdeck.MetricsQueryParams{Measures: []string{"pending"}, Granularity: "1h", DeliveryGroup: "dg_1"}, + wantErr: "--delivery-group", + }, + { + name: "pending timeseries ignores status", + params: hookdeck.MetricsQueryParams{Measures: []string{"pending"}, Granularity: "1h", Status: "SUCCESSFUL"}, + wantErr: "--status", + }, + { + name: "per-issue ignores delivery group", + params: hookdeck.MetricsQueryParams{Dimensions: []string{"issue_id"}, IssueID: "iss_1", DeliveryGroup: "dg_1"}, + wantErr: "--delivery-group", + }, + { + name: "per-issue ignores status", + params: hookdeck.MetricsQueryParams{Dimensions: []string{"issue_id"}, IssueID: "iss_1", Status: "FAILED"}, + wantErr: "--status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := queryEventMetricsConsolidated(context.Background(), nil, tt.params) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Contains(t, err.Error(), "unfiltered", + "the message should say why it matters, not just that the flag is unsupported") + }) + } +} + +// TestPerIssueStillReportsTheMissingIssueIDFirst keeps the more useful error +// ahead of the filter validation. +func TestPerIssueStillReportsTheMissingIssueIDFirst(t *testing.T) { + _, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{ + Dimensions: []string{"issue_id"}, + DeliveryGroup: "dg_1", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--issue-id") +} + +// TestRejectUnsupportedFiltersAllowsWhatTheEndpointHonours is the other half: +// a filter the schema declares must pass through untouched. +func TestRejectUnsupportedFiltersAllowsWhatTheEndpointHonours(t *testing.T) { + err := rejectUnsupportedFilters(hookdeck.MetricsQueryParams{ + SourceID: "src_1", + DestinationID: "des_1", + ConnectionID: "web_1", + Status: "SUCCESSFUL", + DeliveryGroup: "dg_1", + }, hookdeck.DefaultEventRouteFilters, "event metrics") + assert.NoError(t, err) + + err = rejectUnsupportedFilters(hookdeck.MetricsQueryParams{ + DestinationID: "des_1", + DeliveryGroup: "dg_1", + }, hookdeck.QueueDepthRouteFilters, "queue depth metrics") + assert.NoError(t, err) + + // Nothing set is always fine. + assert.NoError(t, rejectUnsupportedFilters(hookdeck.MetricsQueryParams{}, hookdeck.PendingTimeseriesRouteFilters, "pending")) +} diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go index 084dbf11..68aa8c7d 100644 --- a/pkg/cmd/metrics_requests.go +++ b/pkg/cmd/metrics_requests.go @@ -3,14 +3,13 @@ package cmd import ( "context" "fmt" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "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 + cmd *cobra.Command flags metricsCommonFlags } @@ -20,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) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.RequestMetricsFilters, hookdeck.RequestMetricsDimensions, hookdeck.RequestStatusValues) return c } @@ -32,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 a47b6e8d..6ffbce05 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -3,14 +3,13 @@ package cmd import ( "context" "fmt" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "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 + cmd *cobra.Command flags metricsCommonFlags } @@ -20,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) + addMetricsCommonFlags(c.cmd, &c.flags, hookdeck.TransformationMetricsFilters, hookdeck.TransformationMetricsDimensions, hookdeck.TransformationStatusValues) return c } @@ -32,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/project_list.go b/pkg/cmd/project_list.go index db620ee2..2621e8fd 100644 --- a/pkg/cmd/project_list.go +++ b/pkg/cmd/project_list.go @@ -16,19 +16,19 @@ import ( var validProjectTypes = []string{"gateway", "outpost", "console"} type projectListCmd struct { - cmd *cobra.Command - output string - typeFilter string + cmd *cobra.Command + output string + typeFilter string } func newProjectListCmd() *projectListCmd { lc := &projectListCmd{} lc.cmd = &cobra.Command{ - Use: "list [] []", - Args: validators.MaximumNArgs(2), - Short: "List and filter projects by organization and project name substrings", - RunE: lc.runProjectListCmd, + Use: "list [] []", + Args: validators.MaximumNArgs(2), + Short: "List and filter projects by organization and project name substrings", + RunE: lc.runProjectListCmd, Example: `$ hookdeck project list Acme / Ecommerce Production (current) | Gateway Acme / Ecommerce Staging | Gateway @@ -119,7 +119,7 @@ func (lc *projectListCmd) runProjectListCmd(cmd *cobra.Command, args []string) e if it.Org != "" { namePart = it.Org + " / " + it.Project } - fmt.Printf("%s%s | %s\n", namePart, color.Green(" (current)"), it.Type) + fmt.Printf("%s%s | %s\n", namePart, color.Green(" (current)"), config.TypeLabel(it.Type)) } else { fmt.Println(it.DisplayLine()) } diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index 4d6f1e31..b65212da 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -10,7 +10,6 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/spf13/cobra" - "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/project" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -24,10 +23,10 @@ func newProjectUseCmd() *projectUseCmd { lc := &projectUseCmd{} lc.cmd = &cobra.Command{ - Use: "use [ []]", - Args: validators.MaximumNArgs(2), - Short: "Set the active project for future commands", - RunE: lc.runProjectUseCmd, + Use: "use [ []]", + Args: validators.MaximumNArgs(2), + Short: "Set the active project for future commands", + RunE: lc.runProjectUseCmd, Example: `$ hookdeck project use Use the arrow keys to navigate: ↓ ↑ → ← ? Select Project: @@ -119,13 +118,13 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err } } - // Use project by id and mode derived from type - mode := config.ProjectTypeToMode(selected.Type) + // selected.Type is already the API project type. + projectType := selected.Type var configPath string var isNewConfig bool if lc.local { - isNewConfig, err = Config.UseProjectLocal(selected.Id, mode) + isNewConfig, err = Config.UseProjectLocal(selected.Id, projectType) if err != nil { return err } @@ -143,13 +142,13 @@ func (lc *projectUseCmd) runProjectUseCmd(cmd *cobra.Command, args []string) err localConfigExists, _ := Config.FileExists(localConfigPath) if localConfigExists { - isNewConfig, err = Config.UseProjectLocal(selected.Id, mode) + isNewConfig, err = Config.UseProjectLocal(selected.Id, projectType) if err != nil { return err } configPath = localConfigPath } else { - err = Config.UseProject(selected.Id, mode) + err = Config.UseProject(selected.Id, projectType) if err != nil { return 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 37002d91..77227f49 100644 --- a/pkg/cmd/request_events.go +++ b/pkg/cmd/request_events.go @@ -19,6 +19,29 @@ type requestEventsCmd struct { next string prev string output 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 { @@ -30,11 +53,46 @@ 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") @@ -48,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() @@ -58,6 +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/root.go b/pkg/cmd/root.go index 442a75c6..9c57e04f 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -208,8 +208,13 @@ func Execute() { default: if hookdeck.IsUnauthorizedError(err) { - msg := "Authentication failed: your API key is invalid or expired.\n\n" + - "Sign in again: run `hookdeck login` (browser sign-in), or `hookdeck login -i` / `hookdeck --api-key login`.\n\n" + + // Lead with whatever the API said. The generic text is a guess: + // a bare 401 cannot tell an expired key from a wrong-type one. + msg := "Authentication failed: your API key is invalid or expired.\n\n" + if serverMsg := unauthorizedServerMessage(err); serverMsg != "" { + msg = "Authentication failed: " + serverMsg + "\n\n" + } + msg += "Sign in again: run `hookdeck login` (browser sign-in), or `hookdeck login -i` / `hookdeck --api-key login`.\n\n" + "MCP: use hookdeck_login with reauth: true." if gatewayMCP { fmt.Fprintln(os.Stderr, msg) @@ -355,3 +360,25 @@ func init() { // Backward compat: same connection command tree also at root (single definition in newConnectionCmd) addConnectionCmdTo(rootCmd) } + +// unauthorizedServerMessage returns the API's own explanation for a 401, if it +// gave one. These endpoints currently answer with a bare "Unauthorized", so it +// usually returns empty and the caller falls back to generic guidance. +func unauthorizedServerMessage(err error) string { + var apiErr *hookdeck.APIError + if !errors.As(err, &apiErr) { + return "" + } + msg := strings.TrimSpace(apiErr.Message) + // APIError.Message is not always the server's words: for a non-JSON body + // checkAndPrintError synthesizes "unexpected http status code: ..." and + // stores it here. Printing that back is worse than the generic guidance. + if msg == "" || strings.HasPrefix(msg, "unexpected http status code:") { + return "" + } + // The bare status word says nothing the status code did not. + if strings.EqualFold(msg, "unauthorized") { + return "" + } + return msg +} diff --git a/pkg/cmd/sources/types.go b/pkg/cmd/sources/types.go index 28d4a9e1..a399fec0 100644 --- a/pkg/cmd/sources/types.go +++ b/pkg/cmd/sources/types.go @@ -3,16 +3,23 @@ package sources import ( "encoding/json" "fmt" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "io" "net/http" "os" "path/filepath" + "strings" "time" ) var ( - openapiURL = "https://api.hookdeck.com/2025-07-01/openapi" - cacheFileName = "hookdeck_source_types.json" + // Both derived from the one version constant, so they cannot drift apart. + // They did: the URL moved to 2026-09-01 while the cache name stayed put, so + // upgrading within the 24 hour TTL served source types parsed from the + // previous spec - stale auth schemes and required fields, used to validate + // `source create`. + openapiURL = "https://api.hookdeck.com" + hookdeck.APIPathPrefix + "/openapi" + cacheFileName = "hookdeck_source_types" + strings.ReplaceAll(hookdeck.APIPathPrefix, "/", "_") + ".json" cacheTTL = 24 * time.Hour ) 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/cmd/whoami.go b/pkg/cmd/whoami.go index a069f445..aa95ed38 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -44,7 +44,7 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { return err } - projectName, orgName, projectMode, note := resolveActiveProject(response, Config.Profile.ProjectId, func() ([]hookdeck.Project, error) { + projectName, orgName, apiProjectType, note := resolveActiveProject(response, Config.Profile.ProjectId, func() ([]hookdeck.Project, error) { return Config.GetAPIClient().ListProjects() }) @@ -68,39 +68,37 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error { fmt.Printf("%s\n", note) } - projectType := Config.Profile.ProjectType - if projectType == "" && Config.Profile.ProjectMode != "" { - projectType = config.ModeToProjectType(Config.Profile.ProjectMode) + projectType := Config.Profile.ResolveProjectType() + if projectType == "" { + projectType = config.NormalizeProjectType(apiProjectType) } - if projectType == "" && projectMode != "" { - projectType = config.ModeToProjectType(projectMode) - } - if projectType != "" { - fmt.Printf("Project type: %s\n", projectType) + if label := config.TypeLabel(projectType); label != "" { + fmt.Printf("Project type: %s\n", label) } return nil } // resolveActiveProject returns the project name, organization name, and project -// mode to display. /cli-auth/validate resolves the project from the API key's +// type to display. /cli-auth/validate resolves the project from the API key's // bound team and ignores the profile's active project_id, so when the two // differ the active project is looked up via listProjects. A non-empty note is // returned when the active project could not be resolved and the key-bound // values are shown instead. -func resolveActiveProject(response *hookdeck.ValidateAPIKeyResponse, activeProjectID string, listProjects func() ([]hookdeck.Project, error)) (projectName, orgName, projectMode, note string) { +func resolveActiveProject(response *hookdeck.ValidateAPIKeyResponse, activeProjectID string, listProjects func() ([]hookdeck.Project, error)) (projectName, orgName, apiProjectType, note string) { projectName = response.ProjectName orgName = response.OrganizationName - projectMode = response.ProjectMode + // Newest field first: team_type, then the short-lived team_product, then team_mode. + apiProjectType = firstKnownProjectType(response.ProjectType, response.ProjectMode) if activeProjectID == "" || activeProjectID == response.ProjectID { - return projectName, orgName, projectMode, "" + return projectName, orgName, apiProjectType, "" } projects, err := listProjects() if err != nil { note = fmt.Sprintf("Warning: could not look up the active project (%s); showing the project associated with your API key.", activeProjectID) - return projectName, orgName, projectMode, note + return projectName, orgName, apiProjectType, note } for _, p := range projects { @@ -112,9 +110,22 @@ func resolveActiveProject(response *hookdeck.ValidateAPIKeyResponse, activeProje org = "" proj = p.Name } - return proj, org, p.Mode, "" + return proj, org, p.Type, "" } note = fmt.Sprintf("Warning: the active project (%s) was not found; showing the project associated with your API key. Run 'hookdeck project use' to select a project.", activeProjectID) - return projectName, orgName, projectMode, note + return projectName, orgName, apiProjectType, note +} + +// firstKnownProjectType returns the first value that resolves to a known API +// project type. The auth endpoints renamed this field twice, so a response can +// carry any one of team_type, team_product or team_mode depending on how far the +// API has been rolled out. +func firstKnownProjectType(values ...string) string { + for _, v := range values { + if t := config.NormalizeProjectType(v); t != "" { + return t + } + } + return "" } diff --git a/pkg/cmd/whoami_test.go b/pkg/cmd/whoami_test.go index 94810e64..900dd5fa 100644 --- a/pkg/cmd/whoami_test.go +++ b/pkg/cmd/whoami_test.go @@ -11,14 +11,14 @@ func TestResolveActiveProject(t *testing.T) { validateResponse := &hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_bound", ProjectName: "Bound Project", - ProjectMode: "inbound", + ProjectType: "event_gateway", OrganizationName: "Org A", } projects := []hookdeck.Project{ - {Id: "tm_bound", Name: "[Org A] Bound Project", Mode: "inbound"}, - {Id: "tm_active", Name: "[Org B] Active Project", Mode: "outbound"}, - {Id: "tm_unparsable", Name: "No Org Format", Mode: "inbound"}, + {Id: "tm_bound", Name: "[Org A] Bound Project", Type: "event_gateway"}, + {Id: "tm_active", Name: "[Org B] Active Project", Type: "event_gateway"}, + {Id: "tm_unparsable", Name: "No Org Format", Type: "event_gateway"}, } t.Run("no active project id uses validate response", func(t *testing.T) { @@ -30,7 +30,7 @@ func TestResolveActiveProject(t *testing.T) { if called { t.Error("listProjects should not be called when no active project id is set") } - if name != "Bound Project" || org != "Org A" || mode != "inbound" || note != "" { + if name != "Bound Project" || org != "Org A" || mode != "event_gateway" || note != "" { t.Errorf("got (%q, %q, %q, %q)", name, org, mode, note) } }) @@ -44,7 +44,7 @@ func TestResolveActiveProject(t *testing.T) { if called { t.Error("listProjects should not be called when active project matches the key-bound project") } - if name != "Bound Project" || org != "Org A" || mode != "inbound" || note != "" { + if name != "Bound Project" || org != "Org A" || mode != "event_gateway" || note != "" { t.Errorf("got (%q, %q, %q, %q)", name, org, mode, note) } }) @@ -53,7 +53,7 @@ func TestResolveActiveProject(t *testing.T) { name, org, mode, note := resolveActiveProject(validateResponse, "tm_active", func() ([]hookdeck.Project, error) { return projects, nil }) - if name != "Active Project" || org != "Org B" || mode != "outbound" || note != "" { + if name != "Active Project" || org != "Org B" || mode != "event_gateway" || note != "" { t.Errorf("got (%q, %q, %q, %q)", name, org, mode, note) } }) @@ -62,7 +62,7 @@ func TestResolveActiveProject(t *testing.T) { name, org, mode, note := resolveActiveProject(validateResponse, "tm_unparsable", func() ([]hookdeck.Project, error) { return projects, nil }) - if name != "No Org Format" || org != "" || mode != "inbound" || note != "" { + if name != "No Org Format" || org != "" || mode != "event_gateway" || note != "" { t.Errorf("got (%q, %q, %q, %q)", name, org, mode, note) } }) @@ -71,7 +71,7 @@ func TestResolveActiveProject(t *testing.T) { name, org, mode, note := resolveActiveProject(validateResponse, "tm_active", func() ([]hookdeck.Project, error) { return nil, errors.New("boom") }) - if name != "Bound Project" || org != "Org A" || mode != "inbound" { + if name != "Bound Project" || org != "Org A" || mode != "event_gateway" { t.Errorf("got (%q, %q, %q)", name, org, mode) } if note == "" { @@ -83,7 +83,7 @@ func TestResolveActiveProject(t *testing.T) { name, org, mode, note := resolveActiveProject(validateResponse, "tm_deleted", func() ([]hookdeck.Project, error) { return projects, nil }) - if name != "Bound Project" || org != "Org A" || mode != "inbound" { + if name != "Bound Project" || org != "Org A" || mode != "event_gateway" { t.Errorf("got (%q, %q, %q)", name, org, mode) } if note == "" { diff --git a/pkg/config/clear_active_profile_credentials_test.go b/pkg/config/clear_active_profile_credentials_test.go index 7138905c..99fe9a9e 100644 --- a/pkg/config/clear_active_profile_credentials_test.go +++ b/pkg/config/clear_active_profile_credentials_test.go @@ -11,9 +11,11 @@ func TestClearActiveProfileCredentials_MemoryOnly(t *testing.T) { c := &Config{} c.Profile.APIKey = "sk_test_123456789012" c.Profile.ProjectId = "proj_1" + c.Profile.ProjectType = ProjectTypeEventGateway c.Profile.ProjectMode = "inbound" - c.Profile.ProjectType = ProjectTypeGateway + c.Profile.ProjectType = ProjectTypeEventGateway require.NoError(t, c.ClearActiveProfileCredentials()) assert.Empty(t, c.Profile.APIKey) assert.Empty(t, c.Profile.ProjectId) + assert.Empty(t, c.Profile.ProjectType) } diff --git a/pkg/config/config.go b/pkg/config/config.go index fdc95603..5ac8a5ea 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -181,17 +181,16 @@ func (c *Config) InitConfig() { log.SetFormatter(logFormatter) } -// UseProject selects the active project to be used -func (c *Config) UseProject(projectId string, projectMode string) error { - c.Profile.ProjectId = projectId - c.Profile.ProjectMode = projectMode - c.Profile.ProjectType = ModeToProjectType(projectMode) +// UseProject selects the active project. projectType is the API project type; +// display labels and legacy mode values are still accepted for compatibility. +func (c *Config) UseProject(projectId string, projectType string) error { + c.setProjectIdentity(projectId, projectType) return c.Profile.SaveProfile() } // UseProjectLocal selects the active project to be used in local config // Returns true if a new file was created, false if existing file was updated -func (c *Config) UseProjectLocal(projectId string, projectMode string) (bool, error) { +func (c *Config) UseProjectLocal(projectId string, projectType string) (bool, error) { // Get current working directory workingDir, err := os.Getwd() if err != nil { @@ -214,9 +213,7 @@ func (c *Config) UseProjectLocal(projectId string, projectMode string) (bool, er } // Update in-memory state - c.Profile.ProjectId = projectId - c.Profile.ProjectMode = projectMode - c.Profile.ProjectType = ModeToProjectType(projectMode) + c.setProjectIdentity(projectId, projectType) // Write to local config file using shared helper if err := c.writeProjectConfig(localConfigPath, !fileExists); err != nil { @@ -226,6 +223,19 @@ func (c *Config) UseProjectLocal(projectId string, projectMode string) (bool, er return !fileExists, nil } +func (c *Config) setProjectIdentity(projectID, typeOrLegacyMode string) { + c.Profile.ProjectId = projectID + projectType := NormalizeProjectType(typeOrLegacyMode) + c.Profile.ProjectType = projectType + if projectType != "" { + c.Profile.ProjectMode = TypeToLegacyMode(projectType) + return + } + // Unknown value: keep it as the legacy mode rather than discarding it, so a + // future CLI that understands it can still read the config. + c.Profile.ProjectMode = typeOrLegacyMode +} + // writeProjectConfig writes the current profile's project configuration to the specified config file func (c *Config) writeProjectConfig(configPath string, isNewFile bool) error { // Create a new viper instance for the config @@ -264,11 +274,7 @@ func (c *Config) setProfileFieldsInViper(v *viper.Viper) { v.Set("profile", c.Profile.Name) v.Set(c.Profile.getConfigField("project_id"), c.Profile.ProjectId) v.Set(c.Profile.getConfigField("project_mode"), c.Profile.ProjectMode) - projectType := c.Profile.ProjectType - if projectType == "" && c.Profile.ProjectMode != "" { - projectType = ModeToProjectType(c.Profile.ProjectMode) - } - v.Set(c.Profile.getConfigField("project_type"), projectType) + v.Set(c.Profile.getConfigField("project_type"), c.Profile.persistedProjectType()) if c.Profile.GuestURL != "" { v.Set(c.Profile.getConfigField("guest_url"), c.Profile.GuestURL) } @@ -384,10 +390,17 @@ func (c *Config) constructConfig() { c.Profile.ProjectMode = stringCoalesce(c.Profile.ProjectMode, c.viper.GetString(c.Profile.getConfigField("project_mode")), c.viper.GetString("project_mode"), c.viper.GetString(c.Profile.getConfigField("workspace_mode")), c.viper.GetString(c.Profile.getConfigField("team_mode")), c.viper.GetString("workspace_mode"), "") - // ProjectType: prefer project_type from config; else derive from project_mode + // ProjectType: prefer project_type, then derive from the legacy mode. + // Configs written before this release stored a display label here, and reads + // go through NormalizeProjectType, so the stored value does not need to be + // normalized on the way in - and must not be, or an unrecognized type is + // discarded at load and written back empty. c.Profile.ProjectType = stringCoalesce(c.Profile.ProjectType, c.viper.GetString(c.Profile.getConfigField("project_type")), c.viper.GetString("project_type"), "") - if c.Profile.ProjectType == "" && c.Profile.ProjectMode != "" { - c.Profile.ProjectType = ModeToProjectType(c.Profile.ProjectMode) + // Recognized values are held as the API type, because consumers compare + // against it directly (pkg/listen tests ProjectType == ProjectTypeConsole). + // An unrecognized one is left alone so it survives back to disk. + if resolved := c.Profile.ResolveProjectType(); resolved != "" { + c.Profile.ProjectType = resolved } c.Profile.GuestURL = stringCoalesce(c.Profile.GuestURL, c.viper.GetString(c.Profile.getConfigField("guest_url")), c.viper.GetString("guest_url"), "") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 22851d2b..6c9000a1 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -245,7 +245,7 @@ func TestInitConfig(t *testing.T) { } c.InitConfig() - assert.Equal(t, "Gateway", c.Profile.ProjectType) + assert.Equal(t, "event_gateway", c.Profile.ProjectType) assert.Equal(t, "", c.Profile.ProjectMode) }) @@ -259,7 +259,12 @@ func TestInitConfig(t *testing.T) { c.InitConfig() assert.Equal(t, "inbound", c.Profile.ProjectMode) - assert.Equal(t, "Gateway", c.Profile.ProjectType) + assert.Equal(t, "event_gateway", c.Profile.ProjectType) + // The upgrade path: a config written before 2026-09-01 has no + // project_type, so it has to be derived from the legacy mode. + // Without this, an upgraded user has an empty product until they + // log in again, and IsGatewayProject("") fails every gateway command. + assert.Equal(t, "event_gateway", c.Profile.ProjectType) }) t.Run("project_type and project_mode - prefer project_type", func(t *testing.T) { @@ -271,7 +276,7 @@ func TestInitConfig(t *testing.T) { } c.InitConfig() - assert.Equal(t, "Outpost", c.Profile.ProjectType) + assert.Equal(t, "outpost", c.Profile.ProjectType) assert.Equal(t, "inbound", c.Profile.ProjectMode) }) } @@ -313,6 +318,25 @@ func TestWriteConfig(t *testing.T) { contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) assert.Contains(t, string(contentBytes), `project_id = 'new_team_id'`) assert.Contains(t, string(contentBytes), `project_type = 'Gateway'`) + // A legacy mode in, the API project type written back out. + assert.Contains(t, string(contentBytes), `project_type = 'Gateway'`) + assert.Contains(t, string(contentBytes), `project_mode = 'inbound'`) + }) + + t.Run("use project with a product", func(t *testing.T) { + t.Parallel() + + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml") + c.InitConfig() + + err := c.UseProject("new_team_id", "outpost") + + assert.NoError(t, err) + contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed()) + assert.Contains(t, string(contentBytes), `project_type = 'Outpost'`) + assert.Contains(t, string(contentBytes), `project_type = 'Outpost'`) + assert.Contains(t, string(contentBytes), `project_mode = 'outpost'`) }) t.Run("use profile", func(t *testing.T) { diff --git a/pkg/config/load_config_file_test.go b/pkg/config/load_config_file_test.go index 25a6f184..4f5e6e46 100644 --- a/pkg/config/load_config_file_test.go +++ b/pkg/config/load_config_file_test.go @@ -27,5 +27,5 @@ project_mode = "inbound" require.Equal(t, "sk_test_123456789012", c.Profile.APIKey) require.Equal(t, "proj_a", c.Profile.ProjectId) require.Equal(t, "inbound", c.Profile.ProjectMode) - require.Equal(t, ProjectTypeGateway, c.Profile.ProjectType) + require.Equal(t, ProjectTypeEventGateway, c.Profile.ProjectType) } diff --git a/pkg/config/profile.go b/pkg/config/profile.go index b1f8d782..364e1924 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -5,9 +5,11 @@ import ( ) type Profile struct { - Name string // profile name - APIKey string - ProjectId string + Name string // profile name + APIKey string + ProjectId string + // ProjectMode is the pre-2026-09-01 vocabulary, kept only so older CLIs + // reading the same config still resolve a project. Use ProjectType. ProjectMode string ProjectType string // display type: Gateway, Outpost, Console GuestURL string // URL to create permanent account for guest users @@ -20,15 +22,36 @@ func (p *Profile) getConfigField(field string) string { return p.Name + "." + field } +// ResolveProjectType returns the API project type for this profile: the stored +// type if there is one, otherwise derived from the legacy mode. Values written +// by older CLIs held a display label, so everything goes through +// NormalizeProjectType rather than being trusted as-is. +func (p *Profile) ResolveProjectType() string { + if t := NormalizeProjectType(p.ProjectType); t != "" { + return t + } + return ModeToType(p.ProjectMode) +} + +// persistedProjectType is the value written to the project_type config key. +// +// The label, not the API type: the file is shared with older CLIs that only +// understand the label, and both versions rewrite it. Reads normalize, so +// nothing is lost. +func (p *Profile) persistedProjectType() string { + if label := TypeLabel(p.ResolveProjectType()); label != "" { + return label + } + // Unrecognized: keep the raw value. It must survive a load-and-save, or this + // CLI erases a setting a newer one relies on. + return p.ProjectType +} + func (p *Profile) SaveProfile() error { p.Config.viper.Set(p.getConfigField("api_key"), p.APIKey) p.Config.viper.Set(p.getConfigField("project_id"), p.ProjectId) p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode) - projectType := p.ProjectType - if projectType == "" && p.ProjectMode != "" { - projectType = ModeToProjectType(p.ProjectMode) - } - p.Config.viper.Set(p.getConfigField("project_type"), projectType) + p.Config.viper.Set(p.getConfigField("project_type"), p.persistedProjectType()) p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL) if err := p.removeLegacyConfigKeys(); err != nil { diff --git a/pkg/config/profile_credentials.go b/pkg/config/profile_credentials.go index fc12d300..71f968fb 100644 --- a/pkg/config/profile_credentials.go +++ b/pkg/config/profile_credentials.go @@ -2,6 +2,36 @@ package config import "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +// resolveType prefers team_type and falls back to the pre-2026-09-01 team_mode. +// Without the fallback a response missing team_type blanks the profile, which +// fails every gateway command. +func resolveType(projectType, legacyMode string) string { + if t := NormalizeProjectType(projectType); t != "" { + return t + } + return ModeToType(legacyMode) +} + +// storeProjectIdentity records what the API said, not only what this CLI could +// resolve. Deriving both fields from a resolved type blanked them for an +// unrecognized type, leaving "current project type is ." on every gateway +// command. Recognized values are normalized; unrecognized ones kept verbatim. +func storeProjectIdentity(p *Profile, rawType, rawMode string) { + resolved := resolveType(rawType, rawMode) + + // Recognized values are held normalized, for the same reason as on load; + // an unrecognized one is kept verbatim so it survives to disk. + p.ProjectType = resolved + if p.ProjectType == "" { + p.ProjectType = rawType + } + + p.ProjectMode = rawMode + if p.ProjectMode == "" { + p.ProjectMode = TypeToLegacyMode(resolved) + } +} + // ApplyValidateAPIKeyResponse updates project fields from GET /cli-auth/validate. // When clearGuestURL is true, GuestURL is cleared (e.g. hookdeck login re-verify). // When false, GuestURL is left unchanged (e.g. gateway PreRun resolving type only). @@ -10,8 +40,7 @@ func (p *Profile) ApplyValidateAPIKeyResponse(resp *hookdeck.ValidateAPIKeyRespo return } p.ProjectId = resp.ProjectID - p.ProjectMode = resp.ProjectMode - p.ProjectType = ModeToProjectType(resp.ProjectMode) + storeProjectIdentity(p, resp.ProjectType, resp.ProjectMode) if clearGuestURL { p.GuestURL = "" } @@ -25,8 +54,7 @@ func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, gue } p.APIKey = resp.APIKey p.ProjectId = resp.ProjectID - p.ProjectMode = resp.ProjectMode - p.ProjectType = ModeToProjectType(resp.ProjectMode) + storeProjectIdentity(p, resp.ProjectType, resp.ProjectMode) p.GuestURL = guestURL } @@ -34,7 +62,6 @@ func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, gue func (p *Profile) ApplyCIClient(ci hookdeck.CIClient) { p.APIKey = ci.APIKey p.ProjectId = ci.ProjectID - p.ProjectMode = ci.ProjectMode - p.ProjectType = ModeToProjectType(ci.ProjectMode) + storeProjectIdentity(p, ci.ProjectType, ci.ProjectMode) p.GuestURL = "" } diff --git a/pkg/config/profile_credentials_test.go b/pkg/config/profile_credentials_test.go index 72772aa4..2eedd5c2 100644 --- a/pkg/config/profile_credentials_test.go +++ b/pkg/config/profile_credentials_test.go @@ -22,11 +22,12 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) { p := &Profile{GuestURL: "https://guest"} p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ ProjectID: "team_1", - ProjectMode: "inbound", + ProjectType: "event_gateway", }, true) require.Equal(t, "team_1", p.ProjectId) + require.Equal(t, "event_gateway", p.ProjectType) require.Equal(t, "inbound", p.ProjectMode) - require.Equal(t, ProjectTypeGateway, p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) require.Empty(t, p.GuestURL) }) @@ -34,7 +35,7 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) { p := &Profile{GuestURL: "https://guest.example/x"} p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ ProjectID: "team_2", - ProjectMode: "console", + ProjectType: "console", }, false) require.Equal(t, "team_2", p.ProjectId) require.Equal(t, ProjectTypeConsole, p.ProjectType) @@ -42,6 +43,60 @@ func TestProfile_ApplyValidateAPIKeyResponse(t *testing.T) { }) } +// TestProfile_LegacyModeFallback covers a response without team_type. Without +// the fallback the profile is blanked and every gateway command fails. +func TestProfile_LegacyModeFallback(t *testing.T) { + t.Run("validate response falls back to team_mode", func(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "team_legacy", + ProjectMode: "outbound", + }, false) + require.Equal(t, "event_gateway", p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) + }) + + t.Run("poll response falls back to team_mode", func(t *testing.T) { + p := &Profile{} + p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ + APIKey: "key", + ProjectID: "team_legacy", + ProjectMode: "console", + }, "") + require.Equal(t, "console", p.ProjectType) + require.Equal(t, ProjectTypeConsole, p.ProjectType) + }) + + t.Run("ci client falls back to team_mode", func(t *testing.T) { + p := &Profile{} + p.ApplyCIClient(hookdeck.CIClient{ + APIKey: "key", + ProjectID: "team_legacy", + ProjectMode: "outpost", + }) + require.Equal(t, "outpost", p.ProjectType) + require.Equal(t, ProjectTypeOutpost, p.ProjectType) + }) + + t.Run("product wins when both are present", func(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "team_both", + ProjectType: "outpost", + ProjectMode: "inbound", + }, false) + require.Equal(t, "outpost", p.ProjectType) + require.Equal(t, ProjectTypeOutpost, p.ProjectType) + }) + + t.Run("both absent leaves the type empty", func(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ProjectID: "team_none"}, false) + require.Empty(t, p.ProjectType) + require.Empty(t, p.ProjectType) + }) +} + func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { t.Run("nil response is no-op", func(t *testing.T) { p := &Profile{APIKey: "k", ProjectId: "p"} @@ -55,11 +110,11 @@ func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ APIKey: "key_from_poll", ProjectID: "team_p", - ProjectMode: "inbound", + ProjectType: "event_gateway", }, "https://guest") require.Equal(t, "key_from_poll", p.APIKey) require.Equal(t, "team_p", p.ProjectId) - require.Equal(t, ProjectTypeGateway, p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) require.Equal(t, "https://guest", p.GuestURL) }) @@ -68,7 +123,7 @@ func TestProfile_ApplyPollAPIKeyResponse(t *testing.T) { p.ApplyPollAPIKeyResponse(&hookdeck.PollAPIKeyResponse{ APIKey: "k123456789012", ProjectID: "t", - ProjectMode: "inbound", + ProjectType: "event_gateway", }, "") require.Empty(t, p.GuestURL) }) @@ -79,11 +134,11 @@ func TestProfile_ApplyCIClient(t *testing.T) { p.ApplyCIClient(hookdeck.CIClient{ APIKey: "ci_key_123456", ProjectID: "team_ci", - ProjectMode: "inbound", + ProjectType: "event_gateway", }) require.Equal(t, "ci_key_123456", p.APIKey) require.Equal(t, "team_ci", p.ProjectId) - require.Equal(t, ProjectTypeGateway, p.ProjectType) + require.Equal(t, ProjectTypeEventGateway, p.ProjectType) require.Empty(t, p.GuestURL) } @@ -121,4 +176,84 @@ team_mode = "inbound" assert.NotContains(t, tomlText, "team_id") assert.NotContains(t, tomlText, "team_mode") assert.Contains(t, tomlText, "project_id") + assert.Contains(t, tomlText, `project_type = 'Gateway'`) +} + +// TestProfile_UnrecognizedTypeIsNotDiscarded covers a project type this CLI does +// not know about. Deriving from an unresolved type wrote empty values over what +// the API sent, leaving "current project type is ." on every gateway command. +func TestProfile_UnrecognizedTypeIsNotDiscarded(t *testing.T) { + p := &Profile{} + p.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "team_future", + ProjectType: "some_future_product", + }, false) + + // The raw value survives for a CLI that understands it. + require.Equal(t, "some_future_product", p.ProjectType) + + // It still does not resolve, which keeps gateway commands from acting on it. + require.Empty(t, p.ResolveProjectType()) + require.False(t, IsGatewayProject(p.ProjectType)) +} + +// TestUnknownProjectTypeSurvivesDisk covers both routes an unrecognized project +// type takes to config.toml; both wrote an empty project_type before this. +// Matters for forward compatibility: if the API adds a fourth type, running this +// CLI once would erase the setting a newer CLI depends on. +func TestUnknownProjectTypeSurvivesDisk(t *testing.T) { + writeAndReload := func(t *testing.T, c *Config) string { + t.Helper() + require.NoError(t, c.Profile.SaveProfile()) + written, err := os.ReadFile(c.viper.ConfigFileUsed()) + require.NoError(t, err) + return string(written) + } + + t.Run("arriving from an auth response", func(t *testing.T) { + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml") + c.InitConfig() + + c.Profile.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "tm_future", + ProjectType: "future_type", + }, false) + + assert.Contains(t, writeAndReload(t, &c), "project_type = 'future_type'") + }) + + t.Run("already on disk, then rewritten", func(t *testing.T) { + path := setupTempConfig(t, "./testdata/default-profile.toml") + require.NoError(t, os.WriteFile(path, []byte(`profile = "default" + +[default] +api_key = "test_key" +project_id = "tm_future" +project_type = "future_type" +`), 0o600)) + + c := Config{LogLevel: "info", ConfigFileFlag: path} + c.InitConfig() + + // Loading must not normalize it away, which is the half that persistence + // alone could not fix. + require.Equal(t, "future_type", c.Profile.ProjectType) + assert.Contains(t, writeAndReload(t, &c), "project_type = 'future_type'") + }) + + t.Run("a recognized type is still stored as its label", func(t *testing.T) { + c := Config{LogLevel: "info"} + c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml") + c.InitConfig() + + c.Profile.ApplyValidateAPIKeyResponse(&hookdeck.ValidateAPIKeyResponse{ + ProjectID: "tm_known", + ProjectType: ProjectTypeEventGateway, + }, false) + + out := writeAndReload(t, &c) + assert.Contains(t, out, "project_type = 'Gateway'", "older CLIs read the label") + assert.Contains(t, out, "project_mode = 'inbound'") + }) } diff --git a/pkg/config/project_type.go b/pkg/config/project_type.go index f7838e27..d29f6a1a 100644 --- a/pkg/config/project_type.go +++ b/pkg/config/project_type.go @@ -2,24 +2,62 @@ package config import "strings" -// Project type display values (user-facing and config). +// Project types as the API names them: `type` on GET /projects, and `team_type` +// on the CLI auth endpoints. These are the values stored in config and passed +// around internally, so the CLI speaks the same vocabulary as the API it calls. const ( - ProjectTypeGateway = "Gateway" - ProjectTypeOutpost = "Outpost" - ProjectTypeConsole = "Console" + ProjectTypeEventGateway = "event_gateway" + ProjectTypeOutpost = "outpost" + ProjectTypeConsole = "console" ) -// OutboundMode is the API mode for outbound projects; treated as Gateway (same as inbound). +// Labels shown to the user. Presentation only: derived at print time, never +// stored, so there is one source of truth for what a project is. +const ( + ProjectLabelGateway = "Gateway" + ProjectLabelOutpost = "Outpost" + ProjectLabelConsole = "Console" +) + +// OutboundMode is the legacy internal mode for outbound projects. The API folds +// inbound and outbound into event_gateway. const OutboundMode = "outbound" -// ModeToProjectType maps API mode to display project type. -// Inbound and outbound both map to Gateway. Returns empty string only for unknown modes. -func ModeToProjectType(mode string) string { +// TypeLabel returns the label shown to the user for an API project type. +func TypeLabel(projectType string) string { + switch strings.ToLower(projectType) { + case ProjectTypeEventGateway: + return ProjectLabelGateway + case ProjectTypeConsole: + return ProjectLabelConsole + case ProjectTypeOutpost: + return ProjectLabelOutpost + default: + return "" + } +} + +// LabelToType maps a display label back to the API type. Needed for config files +// written before project_type held the API value, and for anything that only has +// the label a user was shown. +func LabelToType(label string) string { + switch strings.ToLower(label) { + case strings.ToLower(ProjectLabelGateway): + return ProjectTypeEventGateway + case strings.ToLower(ProjectLabelConsole): + return ProjectTypeConsole + case strings.ToLower(ProjectLabelOutpost): + return ProjectTypeOutpost + default: + return "" + } +} + +// ModeToType maps a legacy internal mode to the API project type. +func ModeToType(mode string) string { switch strings.ToLower(mode) { - case "inbound": - return ProjectTypeGateway - case OutboundMode: - return ProjectTypeGateway // same as inbound for gateway purposes + case "inbound", OutboundMode: + return ProjectTypeEventGateway case "console": return ProjectTypeConsole case "outpost": @@ -29,10 +67,13 @@ func ModeToProjectType(mode string) string { } } -// ProjectTypeToMode maps display type to API mode (for backward compat when only type is set). -func ProjectTypeToMode(projectType string) string { - switch projectType { - case ProjectTypeGateway: +// TypeToLegacyMode returns a representative legacy mode for a project type, kept +// so older CLIs reading the same config still resolve a project. The API folds +// inbound and outbound into event_gateway, so a round trip through the type +// normalizes outbound to inbound. +func TypeToLegacyMode(projectType string) string { + switch strings.ToLower(projectType) { + case ProjectTypeEventGateway: return "inbound" case ProjectTypeConsole: return "console" @@ -43,20 +84,42 @@ func ProjectTypeToMode(projectType string) string { } } -// IsGatewayProject returns true if the given type or mode represents a Gateway project (inbound, outbound, or console). -func IsGatewayProject(typeOrMode string) bool { - switch typeOrMode { - case ProjectTypeGateway, ProjectTypeConsole, "inbound", "outbound", "console": +// NormalizeProjectType accepts an API type, a display label, or a legacy mode and +// returns the API type. Every value read from disk or handed in by a caller goes +// through here, so the three vocabularies converge in one place rather than at +// each call site. +func NormalizeProjectType(value string) string { + lowered := strings.ToLower(strings.TrimSpace(value)) + if lowered == "" { + return "" + } + if TypeLabel(lowered) != "" { + return lowered + } + if t := LabelToType(lowered); t != "" { + return t + } + return ModeToType(lowered) +} + +// IsGatewayProject reports whether the value denotes a project the gateway +// commands can act on. Console projects count: they are Event Gateway projects +// with a different entry point. +func IsGatewayProject(value string) bool { + switch NormalizeProjectType(value) { + case ProjectTypeEventGateway, ProjectTypeConsole: return true default: return false } } -// ProjectTypeToJSON returns the lowercase type for JSON output (gateway, outpost, console). +// ProjectTypeToJSON returns the value used in `--output json` and accepted by the +// `--type` filter. Deliberately not the API type: `gateway` is what the CLI has +// always emitted, and changing it would break anyone parsing that output. func ProjectTypeToJSON(projectType string) string { - switch projectType { - case ProjectTypeGateway: + switch NormalizeProjectType(projectType) { + case ProjectTypeEventGateway: return "gateway" case ProjectTypeOutpost: return "outpost" @@ -66,3 +129,15 @@ func ProjectTypeToJSON(projectType string) string { return strings.ToLower(projectType) } } + +// IsConsoleProject reports whether the first recognized value identifies a +// Console project. Values are given newest-field-first, matching the order the +// CLI reads team_type, team_product and team_mode from an auth response. +func IsConsoleProject(values ...string) bool { + for _, v := range values { + if t := NormalizeProjectType(v); t != "" { + return t == ProjectTypeConsole + } + } + return false +} diff --git a/pkg/config/project_type_test.go b/pkg/config/project_type_test.go index 9f4aae73..9846c048 100644 --- a/pkg/config/project_type_test.go +++ b/pkg/config/project_type_test.go @@ -6,57 +6,143 @@ import ( "github.com/stretchr/testify/assert" ) -func TestModeToProjectType(t *testing.T) { +func TestTypeLabel(t *testing.T) { + tests := []struct { + projectType string + expected string + }{ + {ProjectTypeEventGateway, ProjectLabelGateway}, + {ProjectTypeConsole, ProjectLabelConsole}, + {ProjectTypeOutpost, ProjectLabelOutpost}, + {"EVENT_GATEWAY", ProjectLabelGateway}, + {"unknown", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.projectType, func(t *testing.T) { + assert.Equal(t, tt.expected, TypeLabel(tt.projectType)) + }) + } +} + +func TestLabelToType(t *testing.T) { + tests := []struct { + label string + expected string + }{ + {ProjectLabelGateway, ProjectTypeEventGateway}, + {ProjectLabelConsole, ProjectTypeConsole}, + {ProjectLabelOutpost, ProjectTypeOutpost}, + {"gateway", ProjectTypeEventGateway}, + {"Unknown", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + assert.Equal(t, tt.expected, LabelToType(tt.label)) + }) + } +} + +func TestModeToType(t *testing.T) { tests := []struct { mode string expected string }{ - {"inbound", ProjectTypeGateway}, - {"INBOUND", ProjectTypeGateway}, + {"inbound", ProjectTypeEventGateway}, + {OutboundMode, ProjectTypeEventGateway}, {"console", ProjectTypeConsole}, - {"Console", ProjectTypeConsole}, {"outpost", ProjectTypeOutpost}, - {"outbound", ProjectTypeGateway}, // same as inbound - {"Outbound", ProjectTypeGateway}, + {"Inbound", ProjectTypeEventGateway}, {"unknown", ""}, {"", ""}, } for _, tt := range tests { t.Run(tt.mode, func(t *testing.T) { - got := ModeToProjectType(tt.mode) - assert.Equal(t, tt.expected, got) + assert.Equal(t, tt.expected, ModeToType(tt.mode)) }) } } -func TestProjectTypeToMode(t *testing.T) { +func TestTypeToLegacyMode(t *testing.T) { tests := []struct { projectType string expected string }{ - {ProjectTypeGateway, "inbound"}, + // event_gateway covers both inbound and outbound; "inbound" is the + // representative value written back to config. + {ProjectTypeEventGateway, "inbound"}, {ProjectTypeConsole, "console"}, {ProjectTypeOutpost, "outpost"}, + {"OUTPOST", "outpost"}, + {"unknown", ""}, {"", ""}, - {"Unknown", ""}, } for _, tt := range tests { t.Run(tt.projectType, func(t *testing.T) { - got := ProjectTypeToMode(tt.projectType) - assert.Equal(t, tt.expected, got) + assert.Equal(t, tt.expected, TypeToLegacyMode(tt.projectType)) + }) + } +} + +// TestNormalizeProjectType is the important one: it is the single door every +// value from disk or from a caller goes through, and it has to accept all three +// vocabularies the CLI has used - the API type, the display label written to +// project_type by older CLIs, and the legacy mode. +func TestNormalizeProjectType(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + {"api type", ProjectTypeEventGateway, ProjectTypeEventGateway}, + {"api type outpost", ProjectTypeOutpost, ProjectTypeOutpost}, + {"display label from an older config", ProjectLabelGateway, ProjectTypeEventGateway}, + {"display label console", ProjectLabelConsole, ProjectTypeConsole}, + {"legacy mode inbound", "inbound", ProjectTypeEventGateway}, + {"legacy mode outbound", OutboundMode, ProjectTypeEventGateway}, + {"legacy mode outpost", "outpost", ProjectTypeOutpost}, + {"mixed case", "Event_Gateway", ProjectTypeEventGateway}, + {"surrounding space", " outpost ", ProjectTypeOutpost}, + {"unknown", "something_else", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, NormalizeProjectType(tt.value)) + }) + } +} + +// TestTypeRoundTrip pins the deliberate lossiness: the API folds inbound and +// outbound into one type, so a round trip normalizes outbound to inbound. The +// type survives; the mode does not. +func TestTypeRoundTrip(t *testing.T) { + for _, projectType := range []string{ProjectTypeEventGateway, ProjectTypeConsole, ProjectTypeOutpost} { + t.Run(projectType, func(t *testing.T) { + assert.Equal(t, projectType, LabelToType(TypeLabel(projectType))) }) } + + assert.Equal(t, "inbound", TypeToLegacyMode(ModeToType(OutboundMode)), + "outbound is expected to normalize to inbound through the type mapping") + assert.Equal(t, "inbound", TypeToLegacyMode(ModeToType("inbound"))) } func TestIsGatewayProject(t *testing.T) { - // Gateway = inbound, outbound, or console (type or mode) - trueCases := []string{ProjectTypeGateway, "inbound", "outbound", "console", ProjectTypeConsole} + // Console projects are Event Gateway projects with a different entry point. + trueCases := []string{ + ProjectTypeEventGateway, ProjectTypeConsole, + ProjectLabelGateway, ProjectLabelConsole, + "inbound", "outbound", "EVENT_GATEWAY", + } for _, v := range trueCases { t.Run("true_"+v, func(t *testing.T) { assert.True(t, IsGatewayProject(v)) }) } - falseCases := []string{ProjectTypeOutpost, ""} + + falseCases := []string{ProjectTypeOutpost, ProjectLabelOutpost, "", "unknown"} for _, v := range falseCases { t.Run("false_"+v, func(t *testing.T) { assert.False(t, IsGatewayProject(v)) @@ -64,20 +150,33 @@ func TestIsGatewayProject(t *testing.T) { } } +func TestIsConsoleProject(t *testing.T) { + assert.True(t, IsConsoleProject(ProjectTypeConsole, "", "")) + assert.True(t, IsConsoleProject("", ProjectTypeConsole, ""), "falls through to the legacy product field") + assert.True(t, IsConsoleProject("", "", "console"), "falls through to the legacy mode field") + assert.False(t, IsConsoleProject(ProjectTypeEventGateway, ProjectTypeConsole, ""), + "the first recognized value wins, so a newer field is not overridden by an older one") + assert.False(t, IsConsoleProject("", "", "")) +} + +// TestProjectTypeToJSON guards the user-facing values. `gateway` is what the CLI +// has always emitted in --output json and accepted in --type; the API type is +// deliberately not used here. func TestProjectTypeToJSON(t *testing.T) { tests := []struct { - projectType string - expected string + value string + expected string }{ - {ProjectTypeGateway, "gateway"}, + {ProjectTypeEventGateway, "gateway"}, {ProjectTypeOutpost, "outpost"}, {ProjectTypeConsole, "console"}, + {ProjectLabelGateway, "gateway"}, + {"inbound", "gateway"}, {"", ""}, } for _, tt := range tests { - t.Run(tt.projectType, func(t *testing.T) { - got := ProjectTypeToJSON(tt.projectType) - assert.Equal(t, tt.expected, got) + t.Run(tt.value, func(t *testing.T) { + assert.Equal(t, tt.expected, ProjectTypeToJSON(tt.value)) }) } } 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 51beac71..39f386c4 100644 --- a/pkg/gateway/mcp/project_display_test.go +++ b/pkg/gateway/mcp/project_display_test.go @@ -13,12 +13,12 @@ import ( func TestFillProjectDisplayNameIfNeeded_SetsNameFromAPI(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/2025-07-01/teams" { + if r.URL.Path != hookdeck.APIPathPrefix+"/projects" { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_x", "name": "[Acme] production", "mode": "console"}, + {"id": "proj_x", "name": "[Acme] production", "type": "console"}, }) })) t.Cleanup(srv.Close) @@ -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/server_test.go b/pkg/gateway/mcp/server_test.go index 1867bfcc..ecefb16e 100644 --- a/pkg/gateway/mcp/server_test.go +++ b/pkg/gateway/mcp/server_test.go @@ -98,8 +98,8 @@ func mockAPI(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Serve if handlers == nil { handlers = map[string]http.HandlerFunc{} } - if _, ok := handlers["/2025-07-01/cli-auth/validate"]; !ok { - handlers["/2025-07-01/cli-auth/validate"] = func(w http.ResponseWriter, r *http.Request) { + if _, ok := handlers[hookdeck.APIPathPrefix+"/cli-auth/validate"]; !ok { + handlers[hookdeck.APIPathPrefix+"/cli-auth/validate"] = func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "user_id": "usr_test", "user_name": "Test User", @@ -108,7 +108,7 @@ func mockAPI(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Serve "organization_id": "org_test", "team_id": "proj_test123", "team_name_no_org": "Production", - "team_mode": "console", + "team_type": "console", }) } } @@ -316,7 +316,7 @@ func TestTranslateAPIError(t *testing.T) { func TestSourcesList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/sources": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/sources": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "src_123", "name": "my-source"})) }, }) @@ -330,7 +330,7 @@ func TestSourcesList_Success(t *testing.T) { func TestSourcesGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/sources/src_123": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/sources/src_123": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "src_123", "name": "github-webhooks"}) }, }) @@ -362,7 +362,7 @@ func TestSourcesTool_UnknownAction(t *testing.T) { func TestDestinationsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/destinations": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/destinations": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "des_456", "name": "my-backend"})) }, }) @@ -374,7 +374,7 @@ func TestDestinationsList_Success(t *testing.T) { func TestDestinationsGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/destinations/des_456": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/destinations/des_456": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "des_456", "name": "my-backend"}) }, }) @@ -406,7 +406,7 @@ func TestDestinationsTool_UnknownAction(t *testing.T) { func TestConnectionsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})) }, }) @@ -418,7 +418,7 @@ func TestConnectionsList_Success(t *testing.T) { func TestConnectionsGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}) }, }) @@ -434,12 +434,12 @@ func TestConnectionsGet_Success(t *testing.T) { func TestConnectionsGet_ByName(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})) }, - "/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}) }, @@ -460,11 +460,11 @@ func TestConnectionsGet_MissingID(t *testing.T) { func TestConnectionsPause_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}) }, - "/2025-07-01/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "paused_at": "2025-01-01T00:00:00Z"}) }, @@ -477,12 +477,12 @@ func TestConnectionsPause_Success(t *testing.T) { func TestConnectionsPause_ByName(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})) }, - "/2025-07-01/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1/pause": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "paused_at": "2025-01-01T00:00:00Z"}) }, @@ -503,11 +503,11 @@ func TestConnectionsPause_MissingID(t *testing.T) { func TestConnectionsUnpause_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"}) }, - "/2025-07-01/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1"}) }, @@ -520,12 +520,12 @@ func TestConnectionsUnpause_Success(t *testing.T) { func TestConnectionsUnpause_ByName(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) assert.Equal(t, "stripe-to-backend", r.URL.Query().Get("name")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_conn1", "name": "stripe-to-backend"})) }, - "/2025-07-01/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_conn1/unpause": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "PUT", r.Method) json.NewEncoder(w).Encode(map[string]any{"id": "web_conn1"}) }, @@ -554,7 +554,7 @@ func TestConnectionsTool_UnknownAction(t *testing.T) { func TestConnectionsList_DisabledFilter(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections": func(w http.ResponseWriter, r *http.Request) { // Verify disabled_at[any]=true is sent when disabled=true assert.Equal(t, "true", r.URL.Query().Get("disabled_at[any]")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "web_1"})) @@ -571,7 +571,7 @@ func TestConnectionsList_DisabledFilter(t *testing.T) { func TestTransformationsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/transformations": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/transformations": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "trn_789", "name": "enrich-payload"})) }, }) @@ -583,7 +583,7 @@ func TestTransformationsList_Success(t *testing.T) { func TestTransformationsGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/transformations/trn_789": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/transformations/trn_789": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "trn_789", "name": "enrich-payload", "code": "module.exports = (req) => req"}) }, }) @@ -615,7 +615,7 @@ func TestTransformationsTool_UnknownAction(t *testing.T) { func TestAttemptsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/attempts": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/attempts": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "atm_001", "status": "SUCCESSFUL", "response_status": 200})) }, }) @@ -627,7 +627,7 @@ func TestAttemptsList_Success(t *testing.T) { func TestAttemptsGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/attempts/atm_001": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/attempts/atm_001": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "atm_001", "response_status": 200}) }, }) @@ -659,7 +659,7 @@ func TestAttemptsTool_UnknownAction(t *testing.T) { func TestEventsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_abc", "status": "SUCCESSFUL"})) }, }) @@ -671,7 +671,7 @@ func TestEventsList_Success(t *testing.T) { func TestEventsGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events/evt_abc": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events/evt_abc": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "evt_abc", "status": "SUCCESSFUL"}) }, }) @@ -691,7 +691,7 @@ func TestEventsGet_MissingID(t *testing.T) { func TestEventsRawBody_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events/evt_abc/raw_body": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events/evt_abc/raw_body": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"key":"value"}`)) }, }) @@ -713,7 +713,7 @@ func TestEventsRawBody_Truncation(t *testing.T) { // Generate a body larger than 100KB largeBody := strings.Repeat("x", 150*1024) session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events/evt_big/raw_body": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events/evt_big/raw_body": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(largeBody)) }, }) @@ -733,7 +733,7 @@ func TestEventsTool_UnknownAction(t *testing.T) { func TestEventsList_ConnectionIDMapsToWebhookID(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { // Verify connection_id is mapped to webhook_id assert.Equal(t, "web_123", r.URL.Query().Get("webhook_id")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) @@ -746,7 +746,7 @@ func TestEventsList_ConnectionIDMapsToWebhookID(t *testing.T) { func TestEventsList_BodyFilter(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { assert.JSONEq(t, `{"type":"payment"}`, r.URL.Query().Get("body")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) }, @@ -761,7 +761,7 @@ func TestEventsList_BodyFilter(t *testing.T) { func TestEventsList_PayloadFilters(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, `{"x-test":"1"}`, r.URL.Query().Get("headers")) assert.JSONEq(t, `{"q":"search"}`, r.URL.Query().Get("parsed_query")) assert.Equal(t, "/webhooks", r.URL.Query().Get("path")) @@ -780,19 +780,21 @@ func TestEventsList_PayloadFilters(t *testing.T) { func TestEventsList_MetadataFilters(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "evt_1,evt_2", r.URL.Query().Get("id")) assert.Equal(t, "3", r.URL.Query().Get("attempts")) assert.Equal(t, "cli_abc", r.URL.Query().Get("cli_id")) + assert.Equal(t, "cus_123", r.URL.Query().Get("delivery_group")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) }, }) result := callTool(t, session, "hookdeck_events", map[string]any{ - "action": "list", - "id": "evt_1,evt_2", - "attempts": "3", - "cli_id": "cli_abc", + "action": "list", + "id": "evt_1,evt_2", + "attempts": "3", + "cli_id": "cli_abc", + "delivery_group": "cus_123", }) assert.False(t, result.IsError) } @@ -807,7 +809,7 @@ func TestEventsList_InvalidBodyFilter(t *testing.T) { func TestEventsList_CreatedAtDateRange(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("created_at[gte]")) assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("created_at[lte]")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) @@ -824,7 +826,7 @@ func TestEventsList_CreatedAtDateRange(t *testing.T) { func TestEventsList_SuccessfulAtDateRange(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("successful_at[gte]")) assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("successful_at[lte]")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) @@ -841,7 +843,7 @@ func TestEventsList_SuccessfulAtDateRange(t *testing.T) { func TestEventsList_LastAttemptAtDateRange(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("last_attempt_at[gte]")) assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("last_attempt_at[lte]")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_1"})) @@ -862,7 +864,7 @@ func TestEventsList_LastAttemptAtDateRange(t *testing.T) { func TestRequestsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_001", "source_id": "src_123"})) }, }) @@ -874,7 +876,7 @@ func TestRequestsList_Success(t *testing.T) { func TestRequestsGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests/req_001": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests/req_001": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "req_001"}) }, }) @@ -894,7 +896,7 @@ func TestRequestsGet_MissingID(t *testing.T) { func TestRequestsRawBody_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests/req_001/raw_body": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests/req_001/raw_body": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"payload":"data"}`)) }, }) @@ -915,7 +917,7 @@ func TestRequestsRawBody_MissingID(t *testing.T) { func TestRequestsRawBody_Truncation(t *testing.T) { largeBody := strings.Repeat("y", 150*1024) session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests/req_big/raw_body": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests/req_big/raw_body": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(largeBody)) }, }) @@ -927,12 +929,17 @@ func TestRequestsRawBody_Truncation(t *testing.T) { func TestRequestsEvents_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests/req_001/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests/req_001/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "cus_123", r.URL.Query().Get("delivery_group")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "evt_from_req"})) }, }) - result := callTool(t, session, "hookdeck_requests", map[string]any{"action": "events", "id": "req_001"}) + result := callTool(t, session, "hookdeck_requests", map[string]any{ + "action": "events", + "id": "req_001", + "delivery_group": "cus_123", + }) assert.False(t, result.IsError) assert.Contains(t, textContent(t, result), "evt_from_req") } @@ -947,7 +954,7 @@ func TestRequestsEvents_MissingID(t *testing.T) { func TestRequestsIgnoredEvents_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests/req_001/ignored_events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests/req_001/ignored_events": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "ign_evt_001"})) }, }) @@ -975,7 +982,7 @@ func TestRequestsTool_UnknownAction(t *testing.T) { func TestRequestsList_VerifiedFilter(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "true", r.URL.Query().Get("verified")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_v"})) }, @@ -987,7 +994,7 @@ func TestRequestsList_VerifiedFilter(t *testing.T) { func TestRequestsList_BodyFilter(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { assert.JSONEq(t, `{"event":"test"}`, r.URL.Query().Get("body")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) }, @@ -1002,7 +1009,7 @@ func TestRequestsList_BodyFilter(t *testing.T) { func TestRequestsList_CreatedAtDateRange(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("created_at[gte]")) assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("created_at[lte]")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) @@ -1019,7 +1026,7 @@ func TestRequestsList_CreatedAtDateRange(t *testing.T) { func TestRequestsList_IngestedAtDateRange(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "2026-06-01T00:00:00Z", r.URL.Query().Get("ingested_at[gte]")) assert.Equal(t, "2026-06-09T23:59:59Z", r.URL.Query().Get("ingested_at[lte]")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) @@ -1036,7 +1043,7 @@ func TestRequestsList_IngestedAtDateRange(t *testing.T) { func TestRequestsList_OrderByAndDir(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/requests": func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "created_at", r.URL.Query().Get("order_by")) assert.Equal(t, "desc", r.URL.Query().Get("dir")) json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "req_1"})) @@ -1057,7 +1064,7 @@ func TestRequestsList_OrderByAndDir(t *testing.T) { func TestIssuesList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/issues": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/issues": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse(map[string]any{"id": "iss_001", "type": "delivery", "status": "OPENED"})) }, }) @@ -1069,7 +1076,7 @@ func TestIssuesList_Success(t *testing.T) { func TestIssuesGet_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/issues/iss_001": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/issues/iss_001": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "iss_001", "type": "delivery"}) }, }) @@ -1101,10 +1108,10 @@ func TestIssuesTool_UnknownAction(t *testing.T) { func TestProjectsList_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/teams": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/projects": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_test123", "name": "Production", "mode": "console"}, - {"id": "proj_other", "name": "Staging", "mode": "console"}, + {"id": "proj_test123", "name": "Production", "type": "console"}, + {"id": "proj_other", "name": "Staging", "type": "console"}, }) }, }) @@ -1125,7 +1132,7 @@ func TestProjectsList_Success(t *testing.T) { func TestProjectsList_ForbiddenIncludesReauthHint(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/teams": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/projects": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]any{"message": "not allowed"}) }, @@ -1140,10 +1147,10 @@ func TestProjectsList_ForbiddenIncludesReauthHint(t *testing.T) { func TestProjectsUse_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/teams": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/projects": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_test123", "name": "Production", "mode": "console"}, - {"id": "proj_new", "name": "Staging", "mode": "console"}, + {"id": "proj_test123", "name": "Production", "type": "console"}, + {"id": "proj_new", "name": "Staging", "type": "console"}, }) }, }) @@ -1167,9 +1174,9 @@ func TestProjectsUse_MissingProjectID(t *testing.T) { func TestProjectsUse_ProjectNotFound(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/teams": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/projects": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_test123", "name": "Production", "mode": "console"}, + {"id": "proj_test123", "name": "Production", "type": "console"}, }) }, }) @@ -1213,23 +1220,25 @@ func TestMetricsTool_MissingMeasures(t *testing.T) { func TestMetricsEvents_DefaultRoute(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/events": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/events": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "cus_123", r.URL.Query().Get("filters[delivery_group]")) json.NewEncoder(w).Encode(map[string]any{"data": []any{}, "granularity": "1h"}) }, }) 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"}, + "action": "events", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "delivery_group": "cus_123", }) assert.False(t, result.IsError) } func TestMetricsEvents_QueueDepthRoute(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/queue-depth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/queue-depth": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) }, }) @@ -1245,7 +1254,7 @@ func TestMetricsEvents_QueueDepthRoute(t *testing.T) { func TestMetricsEvents_PendingTimeseriesRoute(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/events-pending-timeseries": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/events-pending-timeseries": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) }, }) @@ -1262,7 +1271,7 @@ func TestMetricsEvents_PendingTimeseriesRoute(t *testing.T) { func TestMetricsEvents_ByIssueRoute(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/events-by-issue": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/events-by-issue": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) }, }) @@ -1273,13 +1282,38 @@ func TestMetricsEvents_ByIssueRoute(t *testing.T) { "end": "2025-01-02T00:00:00Z", "measures": []any{"count"}, "dimensions": []any{"issue_id"}, + // The endpoint filters on issue_id, so the route is meaningless without + // one. This argument used to be absent here and the call still counted + // as a success, which is the behaviour the CLI has always rejected. + "issue_id": "iss_123", }) assert.False(t, result.IsError) } +// TestMetricsEvents_ByIssueRequiresIssueID pins the other half: routing to +// events-by-issue without an issue_id is a caller mistake, not a query. The CLI +// has always said so; MCP used to send the request anyway. +func TestMetricsEvents_ByIssueRequiresIssueID(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/events-by-issue": func(w http.ResponseWriter, r *http.Request) { + t.Fatal("must not reach the API without an issue_id") + }, + }) + + 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"}, + }) + assert.True(t, result.IsError) + assert.Contains(t, textContent(t, result), "issue_id") +} + func TestMetricsRequests_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/requests": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/requests": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) }, }) @@ -1295,7 +1329,7 @@ func TestMetricsRequests_Success(t *testing.T) { func TestMetricsAttempts_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/attempts": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/attempts": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) }, }) @@ -1311,7 +1345,7 @@ func TestMetricsAttempts_Success(t *testing.T) { func TestMetricsTransformations_Success(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/metrics/transformations": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/metrics/transformations": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) }, }) @@ -1344,7 +1378,7 @@ func TestMetricsTool_UnknownAction(t *testing.T) { func TestLoginTool_AlreadyAuthenticated(t *testing.T) { api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth/validate": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/validate": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "user_id": "usr_1", "user_name": "Test User", @@ -1353,7 +1387,7 @@ func TestLoginTool_AlreadyAuthenticated(t *testing.T) { "organization_id": "org_1", "team_id": "tm_1", "team_name_no_org": "Proj", - "team_mode": "inbound", + "team_type": "event_gateway", }) }, }) @@ -1367,22 +1401,22 @@ func TestLoginTool_AlreadyAuthenticated(t *testing.T) { func TestLoginTool_CIScopedKeyStartsLogin(t *testing.T) { api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth/validate": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/validate": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "organization_name": "Org", "organization_id": "org_1", "team_id": "tm_ci", "team_name_no_org": "CI Project", - "team_mode": "inbound", + "team_type": "event_gateway", }) }, - "/2025-07-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "browser_url": "https://hookdeck.com/auth?code=ci-upgrade", - "poll_url": "http://" + r.Host + "/2025-07-01/cli-auth/poll?key=ci-upgrade", + "poll_url": "http://" + r.Host + hookdeck.APIPathPrefix + "/cli-auth/poll?key=ci-upgrade", }) }, - "/2025-07-01/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"claimed": false}) }, }) @@ -1399,17 +1433,17 @@ func TestLoginTool_CIScopedKeyStartsLogin(t *testing.T) { func TestLoginTool_UnauthorizedKeyNoScopedPrefix(t *testing.T) { api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth/validate": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/validate": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("Unauthorized")) }, - "/2025-07-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "browser_url": "https://hookdeck.com/auth?code=revoked", - "poll_url": "http://" + r.Host + "/2025-07-01/cli-auth/poll?key=revoked", + "poll_url": "http://" + r.Host + hookdeck.APIPathPrefix + "/cli-auth/poll?key=revoked", }) }, - "/2025-07-01/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"claimed": false}) }, }) @@ -1425,13 +1459,13 @@ func TestLoginTool_UnauthorizedKeyNoScopedPrefix(t *testing.T) { func TestLoginTool_ReauthStartsFreshLogin(t *testing.T) { api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "browser_url": "https://hookdeck.com/auth?code=reauth", - "poll_url": "http://" + r.Host + "/2025-07-01/cli-auth/poll?key=reauth", + "poll_url": "http://" + r.Host + hookdeck.APIPathPrefix + "/cli-auth/poll?key=reauth", }) }, - "/2025-07-01/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"claimed": false}) }, }) @@ -1462,14 +1496,14 @@ func TestLoginTool_ReturnsURLImmediately(t *testing.T) { // that never completes (simulates user not yet opening browser). authCalled := false api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth": func(w http.ResponseWriter, r *http.Request) { authCalled = true json.NewEncoder(w).Encode(map[string]any{ "browser_url": "https://hookdeck.com/auth?code=abc123", - "poll_url": "http://" + r.Host + "/2025-07-01/cli-auth/poll?key=abc123", + "poll_url": "http://" + r.Host + hookdeck.APIPathPrefix + "/cli-auth/poll?key=abc123", }) }, - "/2025-07-01/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { // Never claimed — user hasn't opened the browser yet. json.NewEncoder(w).Encode(map[string]any{"claimed": false}) }, @@ -1500,13 +1534,13 @@ func TestLoginTool_ReturnsURLImmediately(t *testing.T) { func TestLoginTool_InProgressShowsURL(t *testing.T) { api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "browser_url": "https://hookdeck.com/auth?code=xyz", - "poll_url": "http://" + r.Host + "/2025-07-01/cli-auth/poll?key=xyz", + "poll_url": "http://" + r.Host + hookdeck.APIPathPrefix + "/cli-auth/poll?key=xyz", }) }, - "/2025-07-01/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"claimed": false}) }, }) @@ -1544,13 +1578,13 @@ func TestLoginTool_PollSurvivesAcrossToolCalls(t *testing.T) { // "login cancelled" error instead of "Already authenticated". pollCount := 0 api := mockAPI(t, map[string]http.HandlerFunc{ - "/2025-07-01/cli-auth": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth": func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "browser_url": "https://hookdeck.com/auth?code=survive", - "poll_url": "http://" + r.Host + "/2025-07-01/cli-auth/poll?key=survive", + "poll_url": "http://" + r.Host + hookdeck.APIPathPrefix + "/cli-auth/poll?key=survive", }) }, - "/2025-07-01/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/cli-auth/poll": func(w http.ResponseWriter, r *http.Request) { pollCount++ if pollCount >= 2 { // Simulate user completing browser auth on 2nd poll. @@ -1559,7 +1593,7 @@ func TestLoginTool_PollSurvivesAcrossToolCalls(t *testing.T) { "key": "sk_test_survive12345", "team_id": "proj_survive", "team_name": "Survive Project", - "team_mode": "console", + "team_type": "console", "user_name": "test-user", "organization_name": "test-org", }) @@ -1606,7 +1640,7 @@ func TestLoginTool_PollSurvivesAcrossToolCalls(t *testing.T) { func TestSourcesList_404Error(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/sources": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/sources": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]any{"message": "workspace not found"}) }, @@ -1619,7 +1653,7 @@ func TestSourcesList_404Error(t *testing.T) { func TestSourcesList_422ValidationError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/sources": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/sources": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) json.NewEncoder(w).Encode(map[string]any{"message": "invalid parameter: limit must be positive"}) }, @@ -1632,7 +1666,7 @@ func TestSourcesList_422ValidationError(t *testing.T) { func TestSourcesList_429RateLimitError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/sources": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/sources": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]any{"message": "rate limited"}) }, @@ -1645,7 +1679,7 @@ func TestSourcesList_429RateLimitError(t *testing.T) { func TestEventsGet_APIError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/events/evt_nope": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/events/evt_nope": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]any{"message": "event not found"}) }, @@ -1813,7 +1847,7 @@ func TestHelpTool_UnknownTopicListsAvailable(t *testing.T) { func TestDestinationsGet_500ServerError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/destinations/des_fail": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/destinations/des_fail": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]any{"message": "internal server error"}) }, @@ -1826,7 +1860,7 @@ func TestDestinationsGet_500ServerError(t *testing.T) { func TestConnectionsGet_401UnauthorizedError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/connections/web_bad": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/connections/web_bad": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]any{"message": "invalid api key"}) }, @@ -1839,7 +1873,7 @@ func TestConnectionsGet_401UnauthorizedError(t *testing.T) { func TestIssuesList_422ValidationError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/issues": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/issues": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) json.NewEncoder(w).Encode(map[string]any{"message": "invalid filter: bad_field"}) }, @@ -1852,7 +1886,7 @@ func TestIssuesList_422ValidationError(t *testing.T) { func TestAttemptsList_429RateLimitError(t *testing.T) { session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "/2025-07-01/attempts": func(w http.ResponseWriter, r *http.Request) { + hookdeck.APIPathPrefix + "/attempts": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]any{"message": "too many requests"}) }, diff --git a/pkg/gateway/mcp/telemetry_test.go b/pkg/gateway/mcp/telemetry_test.go index 366b9d40..ea8e0704 100644 --- a/pkg/gateway/mcp/telemetry_test.go +++ b/pkg/gateway/mcp/telemetry_test.go @@ -188,7 +188,7 @@ func TestMCPToolCall_TelemetryHeaderSentToAPI(t *testing.T) { capture := &headerCapture{} session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "GET /2025-07-01/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse( map[string]any{"id": "src_1", "name": "webhook", "url": "https://example.com"}, )) @@ -218,7 +218,7 @@ func TestMCPToolCall_EachCallGetsUniqueInvocationID(t *testing.T) { capture := &headerCapture{} session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "GET /2025-07-01/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse( map[string]any{"id": "src_1", "name": "webhook", "url": "https://example.com"}, )) @@ -248,12 +248,12 @@ func TestMCPToolCall_TelemetryHeaderReflectsAction(t *testing.T) { capture := &headerCapture{} session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "GET /2025-07-01/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse( map[string]any{"id": "src_1", "name": "test-source", "url": "https://example.com"}, )) }), - "GET /2025-07-01/sources/src_1": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/sources/src_1": capture.handler(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"id": "src_1", "name": "test-source", "url": "https://example.com"}) }), }) @@ -279,7 +279,7 @@ func TestMCPToolCall_TelemetryDisabledByConfig(t *testing.T) { capture := &headerCapture{} api := mockAPI(t, map[string]http.HandlerFunc{ - "GET /2025-07-01/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse( map[string]any{"id": "src_1", "name": "test-source", "url": "https://example.com"}, )) @@ -303,7 +303,7 @@ func TestMCPToolCall_TelemetryDisabledByEnvVar(t *testing.T) { capture := &headerCapture{} session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "GET /2025-07-01/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/sources": capture.handler(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(listResponse( map[string]any{"id": "src_1", "name": "test-source", "url": "https://example.com"}, )) @@ -325,10 +325,10 @@ func TestMCPToolCall_MultipleAPICallsSameInvocation(t *testing.T) { capture := &headerCapture{} session := mockAPIWithClient(t, map[string]http.HandlerFunc{ - "GET /2025-07-01/teams": capture.handler(func(w http.ResponseWriter, r *http.Request) { + "GET " + hookdeck.APIPathPrefix + "/projects": capture.handler(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]map[string]any{ - {"id": "proj_abc", "name": "My Project", "mode": "console"}, + {"id": "proj_abc", "name": "My Project", "type": "console"}, }) }), }) 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 5874143b..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,14 +43,45 @@ 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 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, "status", in.String("status")) + 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")) @@ -96,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 ae1310f0..59aa10d2 100644 --- a/pkg/gateway/mcp/tool_help.go +++ b/pkg/gateway/mcp/tool_help.go @@ -105,7 +105,7 @@ scoped to the active project — if the wrong project is active, all results wil Also use this when unsure which project is currently active. Actions: - list — List all projects. data.projects is the array (id, org, project, type gateway/outpost/console, current). meta includes active_project_id, active_project_name (short), and active_project_org when known. Outbound projects are excluded. + list — List all projects. data.projects is the array (id, org, project, type gateway/outpost/console, current). meta includes active_project_id, active_project_name (short), and active_project_org when known. use — Switch the active project for this session (in-memory only). If list or use fails with 401/403 (or similar), the error may mention hookdeck_login with reauth: true — the stored key may be a narrow dashboard API key. @@ -120,7 +120,7 @@ Without arguments when already authenticated: confirms the session is active. When not authenticated: returns a URL the user opens in a browser; poll by calling this tool again. Parameters: - reauth (boolean, optional) — If true, clears stored credentials and starts a new browser login. Use when hookdeck_projects list fails and the key may be a single-project or dashboard API key that cannot list teams.`, + reauth (boolean, optional) — If true, clears stored credentials and starts a new browser login. Use when hookdeck_projects list fails and the key may be a single-project or dashboard API key that cannot list projects.`, "hookdeck_connections": `hookdeck_connections — Inspect connections and control delivery flow @@ -185,38 +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) - -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) @@ -235,6 +255,7 @@ Parameters: connection_id (string) — Filter by connection (list, maps to webhook_id) source_id (string) — Filter by source (list) destination_id (string) — Filter by destination (list) + delivery_group (string) — Filter by delivery group (list) status (string) — SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED attempts (string) — Filter by attempt count (list); integer or API operator syntax issue_id (string) — Filter by issue (list) @@ -309,13 +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 - 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 9866b815..032234ad 100644 --- a/pkg/gateway/mcp/tool_metrics.go +++ b/pkg/gateway/mcp/tool_metrics.go @@ -36,6 +36,36 @@ func handleMetrics(client *hookdeck.Client) mcpsdk.ToolHandler { } } +// rejectFilters names filters the way an MCP client passes them. +func rejectFilters(params hookdeck.MetricsQueryParams, allowed hookdeck.MetricsFilters, route string) error { + 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. +func mapDimensions(dimensions []string) []string { + if len(dimensions) == 0 { + return dimensions + } + out := make([]string, 0, len(dimensions)) + for _, d := range dimensions { + if d == "connection_id" { + d = "webhook_id" + } + out = append(out, d) + } + return out +} + func buildMetricsParams(in input) (hookdeck.MetricsQueryParams, error) { start := in.String("start") end := in.String("end") @@ -52,9 +82,10 @@ func buildMetricsParams(in input) (hookdeck.MetricsQueryParams, error) { End: end, Granularity: in.String("granularity"), Measures: measures, - Dimensions: in.StringSlice("dimensions"), + Dimensions: mapDimensions(in.StringSlice("dimensions")), SourceID: in.String("source_id"), DestinationID: in.String("destination_id"), + DeliveryGroup: in.String("delivery_group"), ConnectionID: in.String("connection_id"), Status: in.String("status"), IssueID: in.String("issue_id"), @@ -79,16 +110,72 @@ func metricsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mcp return ErrorResult(err.Error()), nil } - // Route to the correct events metrics endpoint based on measures/dimensions + // 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"): - result, err = client.QueryQueueDepth(ctx, params) - case containsAny(params.Measures, "pending") && params.Granularity != "": - result, err = client.QueryEventsPendingTimeseries(ctx, params) + 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 + } + 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 + // route. Without this the request carries a measure the endpoint does + // not define - the CLI has always rewritten it, MCP did not. + pendingParams := params + pendingParams.Measures = []string{"count"} + result, err = client.QueryEventsPendingTimeseries(ctx, pendingParams) case containsAny(params.Dimensions, "issue_id") || params.IssueID != "": + if params.IssueID == "" { + return ErrorResult("per-issue metrics require issue_id (required when using dimensions: issue_id)"), 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: + // 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) } @@ -103,6 +190,12 @@ func metricsRequests(ctx context.Context, client *hookdeck.Client, in input) (*m if err != nil { return ErrorResult(err.Error()), nil } + 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 @@ -115,6 +208,12 @@ func metricsAttempts(ctx context.Context, client *hookdeck.Client, in input) (*m if err != nil { return ErrorResult(err.Error()), nil } + 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 @@ -127,6 +226,12 @@ func metricsTransformations(ctx context.Context, client *hookdeck.Client, in inp if err != nil { return ErrorResult(err.Error()), nil } + 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 new file mode 100644 index 00000000..34415422 --- /dev/null +++ b/pkg/gateway/mcp/tool_metrics_filters_test.go @@ -0,0 +1,245 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// TestMetricsToolRejectsFiltersTheEndpointIgnores is the MCP counterpart of +// TestMetricsFlagsMatchTheEndpointSchemas. Both read the same matrix, so the +// layers cannot drift - which is what happened when the CLI fix landed and this +// one was not touched. Expectations are hardcoded; see the pkg/cmd note. +func TestMetricsToolRejectsFiltersTheEndpointIgnores(t *testing.T) { + tests := []struct { + name string + action string + filter string + endpoint string + }{ + {"requests ignores destination", "requests", "destination_id", "/metrics/requests"}, + {"requests ignores connection", "requests", "connection_id", "/metrics/requests"}, + {"requests ignores delivery group", "requests", "delivery_group", "/metrics/requests"}, + {"attempts ignores source", "attempts", "source_id", "/metrics/attempts"}, + {"attempts ignores connection", "attempts", "connection_id", "/metrics/attempts"}, + {"transformations ignores source", "transformations", "source_id", "/metrics/transformations"}, + {"transformations ignores status", "transformations", "status", "/metrics/transformations"}, + {"transformations ignores delivery group", "transformations", "delivery_group", "/metrics/transformations"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + tt.endpoint: func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("must not call the API with a filter it would ignore (%s)", tt.filter) + }, + }) + + result := callTool(t, session, "hookdeck_metrics", map[string]any{ + "action": tt.action, + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + tt.filter: "x_bogus", + }) + + assert.True(t, result.IsError, "%s with %s must be refused", tt.action, tt.filter) + body := textContent(t, result) + assert.Contains(t, body, tt.filter) + assert.Contains(t, body, "unfiltered", "the message should say why it matters") + }) + } +} + +// TestMetricsToolAcceptsFiltersTheEndpointHonours is the other half: a filter +// the schema does declare must still reach the API. +func TestMetricsToolAcceptsFiltersTheEndpointHonours(t *testing.T) { + var sawFilter string + session := mockAPIWithClient(t, map[string]http.HandlerFunc{ + hookdeck.APIPathPrefix + "/metrics/attempts": func(w http.ResponseWriter, r *http.Request) { + sawFilter = 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": "attempts", + "start": "2025-01-01T00:00:00Z", + "end": "2025-01-02T00:00:00Z", + "measures": []any{"count"}, + "destination_id": "des_123", + }) + + assert.False(t, result.IsError) + assert.Equal(t, "des_123", sawFilter) +} + +// TestMetricsToolMapsConnectionDimension covers the tool schema's own claim that +// connection_id "maps to webhook_id". That was true of the filter and not of the +// dimension, so a caller grouping by connection sent a dimension the API does +// not define. +func TestMetricsToolMapsConnectionDimension(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) + 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_projects_errors.go b/pkg/gateway/mcp/tool_projects_errors.go index 08f19d68..7236ac1f 100644 --- a/pkg/gateway/mcp/tool_projects_errors.go +++ b/pkg/gateway/mcp/tool_projects_errors.go @@ -9,7 +9,7 @@ import ( "github.com/hookdeck/hookdeck-cli/pkg/project" ) -const listProjectsReauthHint = `This may happen if the stored key is a dashboard or single-project API key that cannot list all teams/projects. Try hookdeck_login with reauth: true so the user can sign in via the browser and replace the credential with a full CLI session, then retry hookdeck_projects.` +const listProjectsReauthHint = `This may happen if the stored key is a dashboard or single-project API key that cannot list all projects. Try hookdeck_login with reauth: true so the user can sign in via the browser and replace the credential with a full CLI session, then retry hookdeck_projects.` func listProjectsFailureMessage(err error) string { base := TranslateAPIError(err) diff --git a/pkg/gateway/mcp/tool_requests.go b/pkg/gateway/mcp/tool_requests.go index 655ae625..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,7 +120,42 @@ func requestsEvents(ctx context.Context, client *hookdeck.Client, in input) (*mc if id == "" { return ErrorResult("id is required for the events action"), nil } - result, err := client.GetRequestEvents(ctx, id, 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 } @@ -119,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 59fba1ec..bffe5728 100644 --- a/pkg/gateway/mcp/tools.go +++ b/pkg/gateway/mcp/tools.go @@ -97,27 +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)"}, - "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), }, @@ -125,34 +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)"}, - "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), }, @@ -195,19 +148,20 @@ func toolDefs(client *hookdeck.Client) []struct { { tool: &mcpsdk.Tool{ Name: "hookdeck_metrics", - Description: "Query aggregate metrics over a time range. Get counts, failure rates, error rates, queue depth, and pending event data for events, requests, attempts, and transformations. Supports grouping by dimensions like source, destination, or connection. Results are scoped to the active project — call `hookdeck_projects` first if the user has specified a project.", + Description: "Query aggregate metrics over a time range. Get counts, failure rates, error rates, queue depth, and pending event data for events, requests, attempts, and transformations. Supports grouping by dimensions like source, destination, or connection. Filters apply only to the actions named in each argument: passing one elsewhere is rejected, because the API would ignore it and return unfiltered totals. 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: "Metric type: events, requests, attempts, or transformations", Enum: []string{"events", "requests", "attempts", "transformations"}}, "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"}}, - "source_id": {Type: "string", Desc: "Filter by source"}, - "destination_id": {Type: "string", Desc: "Filter by destination"}, - "connection_id": {Type: "string", Desc: "Filter by connection (maps to webhook_id)"}, - "status": {Type: "string", Desc: "Filter by status"}, - "issue_id": {Type: "string", Desc: "Filter by issue (events only)"}, + "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: descMetricsStatus}, + "issue_id": {Type: "string", Desc: "Filter by issue (transformations; events when grouping by issue_id)"}, }, "action", "start", "end", "measures"), }, handler: handleMetrics(client), @@ -225,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"` @@ -234,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/auth.go b/pkg/hookdeck/auth.go index 9d3560a4..2e9383ee 100644 --- a/pkg/hookdeck/auth.go +++ b/pkg/hookdeck/auth.go @@ -26,8 +26,12 @@ type ValidateAPIKeyResponse struct { OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` ProjectName string `json:"team_name_no_org"` - ProjectMode string `json:"team_mode"` - ClientID string `json:"client_id"` + ProjectType string `json:"team_type"` + // ProjectMode is the pre-2026-09-01 name for the same field, read so a + // response from an API serving the older shape still resolves a project + // type rather than blanking it. + ProjectMode string `json:"team_mode"` + ClientID string `json:"client_id"` } // PollAPIKeyResponse returns the data of the polling client login @@ -40,9 +44,13 @@ type PollAPIKeyResponse struct { OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` ProjectName string `json:"team_name"` - ProjectMode string `json:"team_mode"` - APIKey string `json:"key"` - ClientID string `json:"client_id"` + ProjectType string `json:"team_type"` + // ProjectMode is the pre-2026-09-01 name for the same field, read so a + // response from an API serving the older shape still resolves a project + // type rather than blanking it. + ProjectMode string `json:"team_mode"` + APIKey string `json:"key"` + ClientID string `json:"client_id"` } // UpdateClientInput represents the input for updating a CLI client diff --git a/pkg/hookdeck/auth_test.go b/pkg/hookdeck/auth_test.go index e8e202e0..981f2a3c 100644 --- a/pkg/hookdeck/auth_test.go +++ b/pkg/hookdeck/auth_test.go @@ -30,7 +30,7 @@ func TestValidateAPIKey_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *test OrganizationID: "o1", ProjectID: "t1", ProjectName: "p", - ProjectMode: "gateway", + ProjectType: "event_gateway", }) })) t.Cleanup(server.Close) @@ -49,4 +49,5 @@ func TestValidateAPIKey_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *test require.False(t, sawTeamHeader, "validate must not send X-Team-ID") require.False(t, sawProjectHeader, "validate must not send X-Project-ID") require.Equal(t, "t1", resp.ProjectID) + require.Equal(t, "event_gateway", resp.ProjectType) } diff --git a/pkg/hookdeck/ci.go b/pkg/hookdeck/ci.go index f024c3cd..08684d69 100644 --- a/pkg/hookdeck/ci.go +++ b/pkg/hookdeck/ci.go @@ -15,9 +15,13 @@ type CIClient struct { OrganizationID string `json:"organization_id"` ProjectID string `json:"team_id"` ProjectName string `json:"team_name"` - ProjectMode string `json:"team_mode"` - APIKey string `json:"key"` - ClientID string `json:"client_id"` + ProjectType string `json:"team_type"` + // ProjectMode is the pre-2026-09-01 name for the same field, read so a + // response from an API serving the older shape still resolves a project + // type rather than blanking it. + ProjectMode string `json:"team_mode"` + APIKey string `json:"key"` + ClientID string `json:"client_id"` } type CreateCIClientInput struct { diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 88dbdb29..aab40982 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -36,7 +36,7 @@ const DefaultProfileName = "default" // APIPathPrefix is the versioned path prefix for all REST API requests. // Used by connections, sources, destinations, events, auth, etc. // Change in one place when the API version is updated. -const APIPathPrefix = "/2025-07-01" +const APIPathPrefix = "/2026-09-01" // Client is the API client used to sent requests to Hookdeck. type Client struct { @@ -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/events.go b/pkg/hookdeck/events.go index 7cd31b8e..fe058ba4 100644 --- a/pkg/hookdeck/events.go +++ b/pkg/hookdeck/events.go @@ -15,6 +15,7 @@ type Event struct { WebhookID string `json:"webhook_id"` SourceID string `json:"source_id"` DestinationID string `json:"destination_id"` + DeliveryGroup *string `json:"delivery_group,omitempty"` RequestID string `json:"request_id"` Attempts int `json:"attempts"` ResponseStatus *int `json:"response_status,omitempty"` diff --git a/pkg/hookdeck/metrics.go b/pkg/hookdeck/metrics.go index 93133326..4f3aa6d6 100644 --- a/pkg/hookdeck/metrics.go +++ b/pkg/hookdeck/metrics.go @@ -31,6 +31,7 @@ type MetricsQueryParams struct { Dimensions []string SourceID string DestinationID string + DeliveryGroup string // sent as filters[delivery_group] ConnectionID string // sent as filters[webhook_id] Status string // e.g. SUCCESSFUL, FAILED IssueID string // sent as filters[issue_id]; required for events-by-issue @@ -57,6 +58,9 @@ func buildMetricsQuery(p MetricsQueryParams) string { if p.DestinationID != "" { q.Set("filters[destination_id]", p.DestinationID) } + if p.DeliveryGroup != "" { + q.Set("filters[delivery_group]", p.DeliveryGroup) + } if p.ConnectionID != "" { q.Set("filters[webhook_id]", p.ConnectionID) } 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 new file mode 100644 index 00000000..6878a8f2 --- /dev/null +++ b/pkg/hookdeck/metrics_filters.go @@ -0,0 +1,426 @@ +package hookdeck + +import ( + "fmt" + "strings" +) + +// MetricsFilters names the filters a metrics endpoint actually honours. +// +// The API drops a filter its schema does not declare and returns unfiltered +// totals, so offering one where it has no effect is worse than omitting it. +// Shared by the CLI and MCP layers so they cannot drift apart. +type MetricsFilters struct { + SourceID bool + DestinationID bool + ConnectionID bool + Status bool + IssueID bool + DeliveryGroup bool +} + +// Filters honoured by each metrics endpoint. Keep in step with the API. +var ( + RequestMetricsFilters = MetricsFilters{SourceID: true, Status: true} + AttemptMetricsFilters = MetricsFilters{DestinationID: true, Status: true, DeliveryGroup: true} + TransformationMetricsFilters = MetricsFilters{ConnectionID: true, IssueID: true} + + // The four endpoints `events` can route to, depending on measures and + // dimensions. The caller offers the union and narrows per route. + EventMetricsFilters = MetricsFilters{SourceID: true, DestinationID: true, ConnectionID: true, Status: true, IssueID: true, DeliveryGroup: true} + DefaultEventRouteFilters = MetricsFilters{SourceID: true, DestinationID: true, ConnectionID: true, Status: true, DeliveryGroup: true} + QueueDepthRouteFilters = MetricsFilters{DestinationID: true, DeliveryGroup: true} + PendingTimeseriesRouteFilters = MetricsFilters{DestinationID: true} + EventsByIssueRouteFilters = MetricsFilters{SourceID: true, DestinationID: true, ConnectionID: true, IssueID: true} +) + +// RejectUnsupportedFilters reports the first filter that was set but is not +// honoured by the endpoint the call routes to. names supplies the caller's own +// spelling for each filter, so a CLI user reads "--source-id" and an MCP client +// reads "source_id". +func RejectUnsupportedFilters(params MetricsQueryParams, allowed MetricsFilters, route string, names MetricsFilterNames) error { + checks := []struct { + set bool + ok bool + name string + }{ + {params.SourceID != "", allowed.SourceID, names.SourceID}, + {params.DestinationID != "", allowed.DestinationID, names.DestinationID}, + {params.ConnectionID != "", allowed.ConnectionID, names.ConnectionID}, + {params.Status != "", allowed.Status, names.Status}, + {params.IssueID != "", allowed.IssueID, names.IssueID}, + {params.DeliveryGroup != "", allowed.DeliveryGroup, names.DeliveryGroup}, + } + for _, c := range checks { + if c.set && !c.ok { + return fmt.Errorf("%s is not supported by %s; the API would ignore it and return unfiltered results", c.name, route) + } + } + return nil +} + +// MetricsFilterNames is how each filter is spelled to the caller. +type MetricsFilterNames struct { + SourceID string + DestinationID string + ConnectionID string + Status string + IssueID string + DeliveryGroup string +} + +// CLIFilterNames spells filters as command-line flags. +var CLIFilterNames = MetricsFilterNames{ + SourceID: "--source-id", + DestinationID: "--destination-id", + ConnectionID: "--connection-id", + Status: "--status", + IssueID: "--issue-id", + DeliveryGroup: "--delivery-group", +} + +// MCPFilterNames spells filters as tool arguments. +var MCPFilterNames = MetricsFilterNames{ + SourceID: "source_id", + DestinationID: "destination_id", + ConnectionID: "connection_id", + Status: "status", + 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 new file mode 100644 index 00000000..d47d95f3 --- /dev/null +++ b/pkg/hookdeck/metrics_test.go @@ -0,0 +1,46 @@ +package hookdeck + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildMetricsQueryIncludesDeliveryGroup(t *testing.T) { + query, err := url.ParseQuery(buildMetricsQuery(MetricsQueryParams{ + Start: "2026-09-01T00:00:00Z", + End: "2026-09-02T00:00:00Z", + DeliveryGroup: "cus_priority", + Dimensions: []string{"delivery_group"}, + })) + require.NoError(t, err) + 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/projects.go b/pkg/hookdeck/projects.go index 19580b68..cab8aedf 100644 --- a/pkg/hookdeck/projects.go +++ b/pkg/hookdeck/projects.go @@ -2,16 +2,17 @@ package hookdeck import ( "context" + "fmt" ) type Project struct { - Id string - Name string - Mode string + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` } func (c *Client) ListProjects() ([]Project, error) { - res, err := c.clientForCLIAuthValidate().Get(context.Background(), APIPathPrefix+"/teams", "", nil) + res, err := c.clientForCLIAuthValidate().Get(context.Background(), APIPathPrefix+"/projects", "", nil) if err != nil { return []Project{}, err } @@ -19,7 +20,11 @@ func (c *Client) ListProjects() ([]Project, error) { return []Project{}, err } projects := []Project{} - postprocessJsonResponse(res, &projects) + // A shape mismatch here used to return an empty list and a nil error, so a + // renamed field or a wrapped envelope read as "you have no projects". + if _, err := postprocessJsonResponse(res, &projects); err != nil { + return []Project{}, fmt.Errorf("failed to parse project list response: %w", err) + } return projects, nil } diff --git a/pkg/hookdeck/projects_test.go b/pkg/hookdeck/projects_test.go index 4e2f8d74..ea58beaf 100644 --- a/pkg/hookdeck/projects_test.go +++ b/pkg/hookdeck/projects_test.go @@ -1,43 +1,43 @@ -package hookdeck - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestListProjects_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) { - var sawTeamHeader bool - var sawProjectHeader bool - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sawTeamHeader = r.Header.Get("X-Team-ID") != "" - sawProjectHeader = r.Header.Get("X-Project-ID") != "" - if r.URL.Path != APIPathPrefix+"/teams" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]Project{{Id: "tm_1", Name: "[Org] Proj", Mode: "inbound"}}) - })) - t.Cleanup(server.Close) - - baseURL, err := url.Parse(server.URL) - require.NoError(t, err) - - client := &Client{ - BaseURL: baseURL, - APIKey: "test_key", - ProjectID: "stale_team_should_not_be_sent", - } - - projects, err := client.ListProjects() - require.NoError(t, err) - require.False(t, sawTeamHeader, "list projects must not send X-Team-ID") - require.False(t, sawProjectHeader, "list projects must not send X-Project-ID") - require.Len(t, projects, 1) -} +package hookdeck + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListProjects_omitsTeamAndProjectHeadersWhenConfigHasProjectID(t *testing.T) { + var sawTeamHeader bool + var sawProjectHeader bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawTeamHeader = r.Header.Get("X-Team-ID") != "" + sawProjectHeader = r.Header.Get("X-Project-ID") != "" + if r.URL.Path != APIPathPrefix+"/projects" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Project{{Id: "tm_1", Name: "[Org] Proj", Type: "event_gateway"}}) + })) + t.Cleanup(server.Close) + + baseURL, err := url.Parse(server.URL) + require.NoError(t, err) + + client := &Client{ + BaseURL: baseURL, + APIKey: "test_key", + ProjectID: "stale_team_should_not_be_sent", + } + + projects, err := client.ListProjects() + require.NoError(t, err) + require.False(t, sawTeamHeader, "list projects must not send X-Team-ID") + require.False(t, sawProjectHeader, "list projects must not send X-Project-ID") + require.Len(t, projects, 1) +} 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/links/links.go b/pkg/listen/links/links.go index 5183bb6a..5e820d80 100644 --- a/pkg/listen/links/links.go +++ b/pkg/listen/links/links.go @@ -6,8 +6,8 @@ package links // DashboardHome returns the dashboard (or console) events link for the // session, omitting the team_id parameter when the project id is unknown so // links never render with an empty value. -func DashboardHome(dashboardBaseURL, consoleBaseURL, projectMode, projectID string) string { - if projectMode == "console" { +func DashboardHome(dashboardBaseURL, consoleBaseURL, projectType, projectID string) string { + if projectType == "console" { if projectID == "" { return consoleBaseURL } @@ -21,8 +21,8 @@ func DashboardHome(dashboardBaseURL, consoleBaseURL, projectMode, projectID stri // DashboardHomeDisplay returns the display text for the DashboardHome link: // the same destination without the team_id query parameter. -func DashboardHomeDisplay(dashboardBaseURL, consoleBaseURL, projectMode string) string { - if projectMode == "console" { +func DashboardHomeDisplay(dashboardBaseURL, consoleBaseURL, projectType string) string { + if projectType == "console" { return consoleBaseURL } return dashboardBaseURL + "/events/cli" @@ -30,8 +30,8 @@ func DashboardHomeDisplay(dashboardBaseURL, consoleBaseURL, projectMode string) // Event returns the dashboard (or console) deep-link for a single event, // omitting the team_id parameter when the project id is unknown. -func Event(dashboardBaseURL, consoleBaseURL, projectMode, projectID, eventID string) string { - if projectMode == "console" { +func Event(dashboardBaseURL, consoleBaseURL, projectType, projectID, eventID string) string { + if projectType == "console" { url := consoleBaseURL + "/?event_id=" + eventID if projectID != "" { url += "&team_id=" + projectID diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index e7ca0e6c..90c27ad1 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -171,7 +171,7 @@ Specify a single destination to update the path. For example, pass a connection DeviceName: config.DeviceName, Key: config.Profile.APIKey, ProjectID: config.Profile.ProjectId, - ProjectMode: config.Profile.ProjectMode, + ProjectType: config.Profile.ProjectType, APIBaseURL: config.APIBaseURL, DashboardBaseURL: config.DashboardBaseURL, ConsoleBaseURL: config.ConsoleBaseURL, @@ -195,7 +195,7 @@ Specify a single destination to update the path. For example, pass a connection APIBaseURL: config.APIBaseURL, DashboardBaseURL: config.DashboardBaseURL, ConsoleBaseURL: config.ConsoleBaseURL, - ProjectMode: config.Profile.ProjectMode, + ProjectType: config.Profile.ProjectType, ProjectID: projectID, GuestURL: guestURL, TargetURL: URL, diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index d8cab6cc..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 @@ -81,10 +100,8 @@ func printSourcesWithConnections(config *config.Config, projectID string, source if guestURL != "" { fmt.Printf("💡 Sign up to make your webhook URL permanent: %s\n", guestURL) } else { - url := links.DashboardHome(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectMode, projectID) - displayURL := links.DashboardHomeDisplay(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectMode) - // 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) + url := links.DashboardHome(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectType, projectID) + displayURL := links.DashboardHomeDisplay(config.DashboardBaseURL, config.ConsoleBaseURL, config.Profile.ProjectType) + 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 62d616fb..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 @@ -38,7 +47,7 @@ type Config struct { // Key is the API key used to authenticate with Hookdeck Key string ProjectID string - ProjectMode string + ProjectType string URL *url.URL APIBaseURL string DashboardBaseURL string @@ -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 7a20c4da..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) @@ -58,7 +63,7 @@ type RendererConfig struct { APIBaseURL string DashboardBaseURL string ConsoleBaseURL string - ProjectMode string + ProjectType string ProjectID string GuestURL string TargetURL *url.URL diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index fe698b91..c6b3dd84 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/listen/tui" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -28,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 @@ -38,7 +52,7 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { APIBaseURL: cfg.APIBaseURL, DashboardBaseURL: cfg.DashboardBaseURL, ConsoleBaseURL: cfg.ConsoleBaseURL, - ProjectMode: cfg.ProjectMode, + ProjectType: cfg.ProjectType, ProjectID: cfg.ProjectID, GuestURL: cfg.GuestURL, TargetURL: cfg.TargetURL, @@ -49,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()) @@ -57,6 +76,7 @@ func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { teaProgram: program, teaModel: &model, doneCh: make(chan struct{}), + sendMsg: program.Send, } // Start TUI in background @@ -80,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) @@ -116,7 +146,7 @@ func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket color := ansi.Color(os.Stdout) var displayURL string - if r.cfg.ProjectMode == "console" { + if r.cfg.ProjectType == config.ProjectTypeConsole { displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID } else { displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID @@ -138,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) @@ -180,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 @@ -209,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 @@ -227,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 59783766..ec022ede 100644 --- a/pkg/listen/proxy/renderer_simple.go +++ b/pkg/listen/proxy/renderer_simple.go @@ -9,6 +9,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -124,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 @@ -136,7 +144,7 @@ func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Atte // Build display URL var displayURL string - if r.cfg.ProjectMode == "console" { + if r.cfg.ProjectType == config.ProjectTypeConsole { displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID } else { displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID 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/links.go b/pkg/listen/tui/links.go index ef4a94e3..3099261e 100644 --- a/pkg/listen/tui/links.go +++ b/pkg/listen/tui/links.go @@ -5,11 +5,11 @@ import "github.com/hookdeck/hookdeck-cli/pkg/listen/links" // dashboardHomeURL returns the dashboard (or console) events link for the // session. See pkg/listen/links for the shared construction rules. func dashboardHomeURL(cfg *Config) string { - return links.DashboardHome(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectMode, cfg.ProjectID) + return links.DashboardHome(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectType, cfg.ProjectID) } // eventDashboardURL returns the dashboard (or console) deep-link for a single // event. See pkg/listen/links for the shared construction rules. func eventDashboardURL(cfg *Config, eventID string) string { - return links.Event(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectMode, cfg.ProjectID, eventID) + return links.Event(cfg.DashboardBaseURL, cfg.ConsoleBaseURL, cfg.ProjectType, cfg.ProjectID, eventID) } diff --git a/pkg/listen/tui/links_test.go b/pkg/listen/tui/links_test.go index c8830f38..2a165158 100644 --- a/pkg/listen/tui/links_test.go +++ b/pkg/listen/tui/links_test.go @@ -17,7 +17,7 @@ func TestDashboardHomeURLUsesConfigFields(t *testing.T) { } assert.Equal(t, "https://dashboard.hookdeck.com/events/cli?team_id=tm_123", dashboardHomeURL(cfg)) - cfg.ProjectMode = "console" + cfg.ProjectType = "console" assert.Equal(t, "https://console.hookdeck.com?team_id=tm_123", dashboardHomeURL(cfg)) } @@ -29,6 +29,6 @@ func TestEventDashboardURLUsesConfigFields(t *testing.T) { } assert.Equal(t, "https://dashboard.hookdeck.com/events/evt_1?team_id=tm_123", eventDashboardURL(cfg, "evt_1")) - cfg.ProjectMode = "console" + cfg.ProjectType = "console" assert.Equal(t, "https://console.hookdeck.com/?event_id=evt_1&team_id=tm_123", eventDashboardURL(cfg, "evt_1")) } diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index 4260e25c..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 @@ -91,7 +117,7 @@ type Config struct { APIBaseURL string DashboardBaseURL string ConsoleBaseURL string - ProjectMode string + ProjectType string ProjectID string GuestURL string TargetURL *url.URL @@ -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/claimed_cli_key.go b/pkg/login/claimed_cli_key.go index 8200aeb7..0097a94a 100644 --- a/pkg/login/claimed_cli_key.go +++ b/pkg/login/claimed_cli_key.go @@ -1,54 +1,54 @@ -package login - -import ( - "fmt" - "os" - "strings" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" - "github.com/hookdeck/hookdeck-cli/pkg/validators" -) - -// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console -// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser -// device auth or guest sandbox claim—even when the existing profile is a guest Console session. -func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { - cli_key = strings.TrimSpace(cli_key) - if cli_key == "" { - return fmt.Errorf("--cli-key is required") - } - if err := validators.APIKey(cli_key); err != nil { - return err - } - - config.Profile.APIKey = cli_key - - spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) - response, err := config.GetAPIClient().ValidateAPIKey() - if err != nil { - ansi.StopSpinner(spinner, "", os.Stdout) - return err - } - - message := SuccessMessage( - response.UserName, - response.UserEmail, - response.OrganizationName, - response.ProjectName, - response.ProjectMode == "console", - ) - ansi.StopSpinner(spinner, message, os.Stdout) - - config.Profile.ApplyValidateAPIKeyResponse(response, true) - - if err := config.Profile.SaveProfile(); err != nil { - return err - } - if err := config.Profile.UseProfile(); err != nil { - return err - } - config.RefreshCachedAPIClient() - - return nil -} +package login + +import ( + "fmt" + "os" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// ConfigureFromClaimedCliKey validates a product-issued CLI key (dashboard onboarding, Console +// destination, etc.) and saves the profile. Unlike Login(), this path does not start browser +// device auth or guest sandbox claim—even when the existing profile is a guest Console session. +func ConfigureFromClaimedCliKey(config *configpkg.Config, cli_key string) error { + cli_key = strings.TrimSpace(cli_key) + if cli_key == "" { + return fmt.Errorf("--cli-key is required") + } + if err := validators.APIKey(cli_key); err != nil { + return err + } + + config.Profile.APIKey = cli_key + + spinner := ansi.StartNewSpinner("Verifying credentials...", os.Stdout) + response, err := config.GetAPIClient().ValidateAPIKey() + if err != nil { + ansi.StopSpinner(spinner, "", os.Stdout) + return err + } + + message := SuccessMessage( + response.UserName, + response.UserEmail, + response.OrganizationName, + response.ProjectName, + configpkg.IsConsoleProject(response.ProjectType, response.ProjectMode), + ) + ansi.StopSpinner(spinner, message, os.Stdout) + + config.Profile.ApplyValidateAPIKeyResponse(response, true) + + if err := config.Profile.SaveProfile(); err != nil { + return err + } + if err := config.Profile.UseProfile(); err != nil { + return err + } + config.RefreshCachedAPIClient() + + return nil +} diff --git a/pkg/login/claimed_cli_key_test.go b/pkg/login/claimed_cli_key_test.go index dda7b466..d4f7425b 100644 --- a/pkg/login/claimed_cli_key_test.go +++ b/pkg/login/claimed_cli_key_test.go @@ -32,7 +32,7 @@ func TestConfigureFromClaimedCliKey_guestProfileReplacesCredentials(t *testing.T "organization_id": "org_1", "team_id": "tm_gateway", "team_name_no_org": "Production", - "team_mode": "inbound", + "team_type": "event_gateway", "client_id": "cl_onboard", }) require.NoError(t, err) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index b00d4080..821d3ada 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -1,6 +1,7 @@ package login import ( + "errors" "fmt" "io" "net/url" @@ -26,6 +27,45 @@ var stdinIsTerminal = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } +// ErrRejectedKeyNoTerminal is returned when the key was rejected and there is no +// terminal to complete browser sign-in with. It names the likely cause because +// "invalid or expired" is often untrue: a project API key is valid but not +// accepted by the CLI auth endpoints, and an org key is not accepted at all. +var ErrRejectedKeyNoTerminal = errors.New( + "the API key was rejected, and browser sign-in needs an interactive terminal; " + + "check the key is a CLI key from hookdeck login rather than a project or organization API key, " + + "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 @@ -45,13 +85,17 @@ func Login(config *configpkg.Config, input io.Reader) error { if !hookdeck.IsUnauthorizedError(err) { return err } - // Rejected key: continue into browser login below (must clear key first - // or we would re-enter this branch only). + // Refuse only where the flow would have to read stdin, mirroring the + // branch in waitForLoginSession. + if !stdinIsTerminal() && browserSignInNeedsStdin() { + return ErrRejectedKeyNoTerminal + } + // Must clear the key first or we would re-enter this branch only. fmt.Fprintln(os.Stdout, "Your saved API key is no longer valid. Starting browser sign-in...") config.Profile.APIKey = "" } else if response.UserID != "" { if config.Profile.GuestURL == "" || !response.UserIsGuest { - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) config.Profile.ApplyValidateAPIKeyResponse(response, true) @@ -79,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 @@ -105,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) @@ -139,7 +198,7 @@ func waitForLoginSession(config *configpkg.Config, input io.Reader, session *hoo config.RefreshCachedAPIClient() - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) return nil @@ -240,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") @@ -250,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) @@ -279,7 +349,7 @@ func waitForGuestUpgrade(config *configpkg.Config, input io.Reader) error { config.RefreshCachedAPIClient() - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, configpkg.IsConsoleProject(response.ProjectType, response.ProjectMode)) ansi.StopSpinner(s, message, os.Stdout) return nil diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go index c60c92f9..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" @@ -53,6 +55,12 @@ func TestLogin_unauthorizedValidateStartsBrowserFlow(t *testing.T) { configpkg.ResetAPIClientForTesting() t.Cleanup(configpkg.ResetAPIClientForTesting) + // Stated explicitly: this test used to pass without stubbing it, because a + // rejected key fell into the browser flow regardless of who could finish it. + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + oldCan := canOpenBrowser oldOpen := openBrowser canOpenBrowser = func() bool { return false } @@ -83,7 +91,7 @@ func TestLogin_unauthorizedValidateStartsBrowserFlow(t *testing.T) { "claimed": true, "key": "hk_test_newkey_abcdefghij", "team_id": "tm_1", - "team_mode": "gateway", + "team_type": "event_gateway", "team_name": "Proj", "user_name": "U", "user_email": "u@example.com", @@ -128,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 } @@ -152,7 +167,7 @@ func TestLogin_guestProfileWithValidKeyStartsGuestUpgrade(t *testing.T) { "organization_id": "org_1", "team_id": "tm_console", "team_name_no_org": "Sandbox", - "team_mode": "console", + "team_type": "console", "client_id": "cl_guest", } enc, err := json.Marshal(resp) @@ -217,7 +232,7 @@ func TestLogin_ciKeyHeadlessFailsFast(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_ci", - ProjectMode: "inbound", + ProjectType: "event_gateway", OrganizationName: "Org", OrganizationID: "org_1", ProjectName: "CI", @@ -276,7 +291,7 @@ func TestLogin_ciKeyStartsBrowserFlow(t *testing.T) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(hookdeck.ValidateAPIKeyResponse{ ProjectID: "tm_ci", - ProjectMode: "inbound", + ProjectType: "event_gateway", OrganizationName: "Org", OrganizationID: "org_1", ProjectName: "CI", @@ -297,7 +312,7 @@ func TestLogin_ciKeyStartsBrowserFlow(t *testing.T) { "key": "hk_test_userkey_abcdefghij", "user_id": "usr_1", "team_id": "tm_1", - "team_mode": "inbound", + "team_type": "event_gateway", "team_name": "Proj", "user_name": "U", "user_email": "u@example.com", @@ -335,3 +350,588 @@ api_key = "hk_test_cikey_abcdefghij" require.Equal(t, 1, pollHits) require.Equal(t, "hk_test_userkey_abcdefghij", cfg.Profile.APIKey) } + +// TestLogin_rejectedKeyHeadlessFailsFast: a key the API rejects, with no +// terminal and no other way to complete sign-in. Without the guard this walked +// past the Enter prompt and polled for a confirmation nobody could give - 248 +// seconds in CI. +func TestLogin_rejectedKeyHeadlessFailsFast(t *testing.T) { + configpkg.ResetAPIClientForTesting() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + oldStdinIsTerminal := stdinIsTerminal + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { stdinIsTerminal = oldStdinIsTerminal }) + + var sawCLIAuthPost bool + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/cli-auth/validate") { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + return + } + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth") { + // Reaching here means we started the browser flow anyway, which is + // the bug: there is nobody to complete it. + sawCLIAuthPost = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"browser_url": "https://example.test", "poll_url": "https://example.test/poll"}) + return + } + 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(`profile = "default" + +[default] +api_key = "hk_test_rejected_abcdefghij" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.DeviceName = "test-device" + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + err = Login(cfg, strings.NewReader("\n")) + require.ErrorIs(t, err, ErrRejectedKeyNoTerminal) + require.False(t, sawCLIAuthPost, "browser sign-in must not be started without a terminal") + require.Contains(t, err.Error(), "CLI key", "the error should say what kind of key is expected") +} + +// TestLogin_rejectedKeyNoBrowserStillSignsIn: no terminal, no browser, stale key. +// waitForLoginSession prints the URL and polls without reading stdin, so this +// completes. The guard once refused it while the same environment with no saved +// key succeeded. +func TestLogin_rejectedKeyNoBrowserStillSignsIn(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 strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + 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(`profile = "default" + +[default] +api_key = "hk_test_stale_abcdefghij" +`), 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") +} + +// 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 Gateway) + // event_gateway, console, and outpost are all known products. require.Len(t, items, 5) // p1: Gateway, Acme, Prod assert.Equal(t, "p1", items[0].Id) assert.Equal(t, "Acme", items[0].Org) assert.Equal(t, "Prod", items[0].Project) - assert.Equal(t, "Gateway", items[0].Type) + assert.Equal(t, "event_gateway", items[0].Type) assert.False(t, items[0].Current) - // p2: current, console mode -> Console type + // p2: current, console type assert.True(t, items[1].Current) - assert.Equal(t, "Console", items[1].Type) + assert.Equal(t, "console", items[1].Type) // p3: Outpost - assert.Equal(t, "Outpost", items[2].Type) + assert.Equal(t, "outpost", items[2].Type) - // p4: outbound -> Gateway (same as inbound) + // p4: event_gateway assert.Equal(t, "p4", items[3].Id) assert.Equal(t, "Org", items[3].Org) - assert.Equal(t, "Outbound", items[3].Project) - assert.Equal(t, "Gateway", items[3].Type) + assert.Equal(t, "Gateway", items[3].Project) + assert.Equal(t, "event_gateway", items[3].Type) // p5: unparseable name -> org "", project "No brackets" assert.Equal(t, "", items[4].Org) @@ -50,36 +50,35 @@ func TestNormalizeProjects_EmptyList(t *testing.T) { assert.Empty(t, items) } -// TestNormalizeProjects_OutboundMapsToGateway ensures outbound mode is treated as Gateway (same as inbound). -func TestNormalizeProjects_OutboundMapsToGateway(t *testing.T) { +func TestNormalizeProjects_KeepsAPIType(t *testing.T) { projects := []hookdeck.Project{ - {Id: "p1", Name: "[A] P", Mode: "outbound"}, + {Id: "p1", Name: "[A] P", Type: "event_gateway"}, } items := NormalizeProjects(projects, "p1") require.Len(t, items, 1) assert.Equal(t, "p1", items[0].Id) - assert.Equal(t, "Gateway", items[0].Type) + assert.Equal(t, "event_gateway", items[0].Type) assert.True(t, items[0].Current) } func TestFilterByType(t *testing.T) { items := []ProjectListItem{ - {Type: "Gateway"}, - {Type: "Outpost"}, - {Type: "Gateway"}, - {Type: "Console"}, + {Type: "event_gateway"}, + {Type: "outpost"}, + {Type: "event_gateway"}, + {Type: "console"}, } got := FilterByType(items, "gateway") require.Len(t, got, 2) - assert.Equal(t, "Gateway", got[0].Type) - assert.Equal(t, "Gateway", got[1].Type) + assert.Equal(t, "event_gateway", got[0].Type) + assert.Equal(t, "event_gateway", got[1].Type) got = FilterByType(items, "") require.Len(t, got, 4) got = FilterByType(items, "console") require.Len(t, got, 1) - assert.Equal(t, "Console", got[0].Type) + assert.Equal(t, "console", got[0].Type) } func TestFilterByOrgProject(t *testing.T) { @@ -98,7 +97,7 @@ func TestFilterByOrgProject(t *testing.T) { } func TestProjectListItem_DisplayLine(t *testing.T) { - it := ProjectListItem{Org: "Acme", Project: "Prod", Type: "Gateway", Current: false} + it := ProjectListItem{Org: "Acme", Project: "Prod", Type: "event_gateway", Current: false} assert.Equal(t, "Acme / Prod | Gateway", it.DisplayLine()) it.Current = true diff --git a/test-scripts/test-api-upsert-behavior.sh b/test-scripts/test-api-upsert-behavior.sh index 6f6c329f..58dc3fc2 100755 --- a/test-scripts/test-api-upsert-behavior.sh +++ b/test-scripts/test-api-upsert-behavior.sh @@ -15,7 +15,7 @@ CONN_NAME="test-api-behavior-$(date +%s)" echo "" echo "=== Step 1: Creating connection with source and destination ===" -CREATE_RESPONSE=$(curl -s -X PUT "https://api.hookdeck.com/2025-07-01/connections" \ +CREATE_RESPONSE=$(curl -s -X PUT "https://api.hookdeck.com/2026-09-01/connections" \ -H "Authorization: Bearer $HOOKDECK_API_KEY" \ -H "Content-Type: application/json" \ -d "{ @@ -37,7 +37,7 @@ CONN_ID=$(echo "$CREATE_RESPONSE" | jq -r '.id') echo "" echo "=== Step 2: Updating ONLY description (no source/destination in request) ===" -UPDATE_RESPONSE=$(curl -s -X PUT "https://api.hookdeck.com/2025-07-01/connections" \ +UPDATE_RESPONSE=$(curl -s -X PUT "https://api.hookdeck.com/2026-09-01/connections" \ -H "Authorization: Bearer $HOOKDECK_API_KEY" \ -H "Content-Type: application/json" \ -d "{ @@ -51,7 +51,7 @@ echo "$UPDATE_RESPONSE" | jq '.' echo "" echo "=== Step 3: Cleanup ===" -curl -s -X DELETE "https://api.hookdeck.com/2025-07-01/connections/$CONN_ID" \ +curl -s -X DELETE "https://api.hookdeck.com/2026-09-01/connections/$CONN_ID" \ -H "Authorization: Bearer $HOOKDECK_API_KEY" > /dev/null echo "Deleted connection $CONN_ID" diff --git a/test/acceptance/README.md b/test/acceptance/README.md index d0c3f163..0720548c 100644 --- a/test/acceptance/README.md +++ b/test/acceptance/README.md @@ -20,7 +20,7 @@ These tests require browser-based authentication via `hookdeck login` and must b **Files:** Test files with `//go:build manual` tag (e.g., `project_use_manual_test.go`) -**Why Manual?** These tests access endpoints (like `/teams`) that require CLI authentication keys obtained through interactive browser login, which aren't available to CI service accounts. +**Why Manual?** These tests access endpoints (like `/projects`) that require CLI authentication keys obtained through interactive browser login, which aren't available to CI service accounts. ### Transient HTTP 502 / 500 from the API @@ -30,7 +30,7 @@ These tests require browser-based authentication via `hookdeck login` and must b Some tests (e.g. `TestTelemetryGatewayConnectionListProxy` in `telemetry_test.go`, `TestTelemetryListenProxy` in `telemetry_listen_test.go`) use a **recording proxy**: the CLI is run with `--api-base` pointing at a local HTTP server that forwards every request to the real Hookdeck API and records method, path, and the `X-Hookdeck-CLI-Telemetry` header. The same `CLIRunner` and `go run main.go` flow are used as in other acceptance tests; only the API base URL is overridden so traffic goes through the proxy. This verifies that a single CLI run sends consistent telemetry (same `invocation_id` and `command_path`) on all API calls. Helpers: `StartRecordingProxy`, `AssertTelemetryConsistent`. -**Login telemetry tests** use the same proxy approach with **HOOKDECK_CLI_TESTING_CLI_KEY** (a CLI client key, not a Project API key used with `hookdeck ci`). If unset, those tests are skipped. **TestTelemetryLoginProxy** runs `hookdeck login --api-key KEY` with `--api-base` set to the proxy and asserts exactly one recorded request (GET `/2025-07-01/cli-auth/validate`) with consistent telemetry. **TestTelemetryLoginCommandFlagsProxy** additionally asserts the telemetry JSON includes **`command_flags`** containing **`api-key`** or **`cli-key`** on the wire when that flag is passed. Other telemetry tests still use the normal Project API key via `NewCLIRunner`. +**Login telemetry tests** use the same proxy approach with **HOOKDECK_CLI_TESTING_CLI_KEY** (a CLI client key, not a Project API key used with `hookdeck ci`). If unset, those tests are skipped. **TestTelemetryLoginProxy** runs `hookdeck login --api-key KEY` with `--api-base` set to the proxy and asserts exactly one recorded request (GET `/2026-09-01/cli-auth/validate`) with consistent telemetry. **TestTelemetryLoginCommandFlagsProxy** additionally asserts the telemetry JSON includes **`command_flags`** containing **`api-key`** or **`cli-key`** on the wire when that flag is passed. Other telemetry tests still use the normal Project API key via `NewCLIRunner`. See **README.md § [CLI authentication keys](../README.md#cli-authentication-keys)** for claimed vs unclaimed keys and how Project API keys relate to `hookdeck ci`. @@ -184,10 +184,10 @@ The [`RequireCLIAuthenticationOnce(t)`](helpers.go:268) helper function: - ✅ `TestLocalConfigHelpers` - Helper function tests, no API calls **Manual Tests (project_use_manual_test.go):** -- 🔐 `TestProjectUseLocalCreatesConfig` - Requires `/teams` endpoint access -- 🔐 `TestProjectUseSmartDefault` - Requires `/teams` endpoint access -- 🔐 `TestProjectUseLocalCreateDirectory` - Requires `/teams` endpoint access -- 🔐 `TestProjectUseLocalSecurityWarning` - Requires `/teams` endpoint access +- 🔐 `TestProjectUseLocalCreatesConfig` - Requires `/projects` endpoint access +- 🔐 `TestProjectUseSmartDefault` - Requires `/projects` endpoint access +- 🔐 `TestProjectUseLocalCreateDirectory` - Requires `/projects` endpoint access +- 🔐 `TestProjectUseLocalSecurityWarning` - Requires `/projects` endpoint access ### Tips for Running Manual Tests @@ -234,12 +234,12 @@ The [`RequireCLIAuthenticationOnce(t)`](helpers.go:268) helper function: - **`project_use_test.go`** - Project use automated tests (CI-compatible) - Flag validation tests - Helper function tests - - Tests that don't require `/teams` endpoint access + - Tests that don't require `/projects` endpoint access - **`project_use_manual_test.go`** - Project use manual tests (requires human auth) - Build tag: `//go:build manual` - Tests that require browser-based authentication - - Tests that access `/teams` endpoint + - Tests that access `/projects` endpoint - **`.env`** - Local environment variables (git-ignored) diff --git a/test/acceptance/connection_delivery_group_test.go b/test/acceptance/connection_delivery_group_test.go new file mode 100644 index 00000000..dc8fc75b --- /dev/null +++ b/test/acceptance/connection_delivery_group_test.go @@ -0,0 +1,325 @@ +//go:build connection_upsert + +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// `gateway connection` carries the same delivery-policy flags as +// `gateway destination`, under a --destination- prefix. The create and upsert +// paths build the destination config through different code, so both need +// covering: connection upsert reaches the stored destination through the +// connection, not through a destination lookup. + +// connectionDestinationDeliveryGroups extracts +// destination.config.delivery_policy.groups from a connection response body. +func connectionDestinationDeliveryGroups(t *testing.T, resp map[string]interface{}) map[string]interface{} { + t.Helper() + dest, ok := resp["destination"].(map[string]interface{}) + require.True(t, ok, "expected destination object on connection, got %T", resp["destination"]) + config, ok := dest["config"].(map[string]interface{}) + require.True(t, ok, "expected destination config object, got %T", dest["config"]) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok, "expected destination config.delivery_policy object, got %v", config["delivery_policy"]) + groups, ok := policy["groups"].(map[string]interface{}) + require.True(t, ok, "expected config.delivery_policy.groups object, got %v", policy["groups"]) + return groups +} + +// cleanupConnectionAndResources deletes the connection and the source and +// destination it created inline. `gateway connection delete` removes only the +// connection, so tests that create resources inline have to delete them too — +// the suite already leaks ~36k sources and destinations (#362). +func cleanupConnectionAndResources(t *testing.T, cli *CLIRunner, resp map[string]interface{}) { + t.Helper() + id := func(key string) string { + nested, ok := resp[key].(map[string]interface{}) + if !ok { + return "" + } + s, _ := nested["id"].(string) + return s + } + connID, _ := resp["id"].(string) + srcID, dstID := id("source"), id("destination") + + t.Cleanup(func() { + if connID != "" { + deleteConnection(t, cli, connID) + } + if dstID != "" { + deleteDestination(t, cli, dstID) + } + if srcID != "" { + deleteSource(t, cli, srcID) + } + }) +} + +// requireNoConnection asserts that no connection exists under the given name. +// Refusing the command is only half the contract; the other half is that +// nothing was created before the refusal. +func requireNoConnection(t *testing.T, cli *CLIRunner, name string) { + t.Helper() + stdout, stderr, err := cli.Run("gateway", "connection", "get", name) + if err == nil { + var resp map[string]interface{} + if jsonErr := cli.RunJSON(&resp, "gateway", "connection", "get", name); jsonErr == nil { + cleanupConnectionAndResources(t, cli, resp) + } + t.Fatalf("rejected command must not create a connection, but %q exists: %s", name, stdout) + } + assert.Contains(t, stdout+stderr, "connection not found", + "expected a not-found error for %q", name) +} + +// connectionSampleOverrides mirrors sampleOverrides in the destination tests; +// the two files can be compiled into the same binary, so the names differ. +func connectionSampleOverrides() map[string]interface{} { + return map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": float64(5), "rate_period": "minute"}, + "cust_2": map[string]interface{}{"rate": float64(50), "rate_period": "second"}, + } +} + +const connectionSampleOverridesJSON = `{"cust_1":{"rate":5,"rate_period":"minute"},"cust_2":{"rate":50,"rate_period":"second"}}` + +// TestConnectionCreateWithDestinationDeliveryGroup creates a connection whose +// inline destination carries a full delivery-group triple plus overrides, then +// reads it back and asserts the stored groups object matches exactly. +func TestConnectionCreateWithDestinationDeliveryGroup(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-create-" + timestamp + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dg-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dg-"+timestamp, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhooks", + "--destination-rate-limit", "100", + "--destination-rate-limit-period", "minute", + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + "--destination-delivery-group-overrides", connectionSampleOverridesJSON, + ) + require.NoError(t, err, "Should create connection with a destination delivery group") + require.NotEmpty(t, created["id"], "Expected connection ID") + cleanupConnectionAndResources(t, cli, created) + + // Read back rather than trusting the create response. + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "connection", "get", connName) + require.NoError(t, err, "Should read the connection back") + + assert.Equal(t, map[string]interface{}{ + "key": "body.customer_id", + "rate": float64(10), + "rate_period": "second", + "overrides": connectionSampleOverrides(), + }, connectionDestinationDeliveryGroups(t, fetched), + "stored delivery_policy.groups must match the flags exactly, overrides included") + + t.Logf("Created connection %s with destination delivery group overrides", connName) +} + +// TestConnectionUpsertPreservesDestinationDeliveryGroupOverrides is the #393 +// regression guard on the connection path. The API replaces +// delivery_policy.groups wholesale, so bumping only the group rate — which +// still has to repeat the key and rate-period — sends a full groups object and +// used to destroy the stored overrides. +func TestConnectionUpsertPreservesDestinationDeliveryGroupOverrides(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-upsert-" + timestamp + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dgu-"+timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dgu-"+timestamp, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhooks", + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + "--destination-delivery-group-overrides", connectionSampleOverridesJSON, + ) + require.NoError(t, err, "Should create connection carrying overrides") + require.NotEmpty(t, created["id"], "Expected connection ID") + cleanupConnectionAndResources(t, cli, created) + + require.Equal(t, connectionSampleOverrides(), connectionDestinationDeliveryGroups(t, created)["overrides"], + "precondition: the destination must be created with overrides") + + // Change ONLY the group rate. + var upserted map[string]interface{} + err = cli.RunJSON(&upserted, "gateway", "connection", "upsert", connName, + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "30", + "--destination-delivery-group-rate-period", "second", + ) + require.NoError(t, err, "Should upsert the group rate") + + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "connection", "get", connName) + require.NoError(t, err, "Should read the connection back after upsert") + + groups := connectionDestinationDeliveryGroups(t, fetched) + assert.Equal(t, float64(30), groups["rate"], "the requested rate change must apply") + assert.Equal(t, "body.customer_id", groups["key"], "the group key must be unchanged") + assert.Equal(t, "second", groups["rate_period"], "the group rate period must be unchanged") + assert.Equal(t, connectionSampleOverrides(), groups["overrides"], + "bumping the group rate must not destroy the stored per-group overrides (#393)") +} + +// TestConnectionDestinationDeliveryPolicyRejectedForCLIType asserts the +// delivery-policy flags are refused against a CLI destination, and that no +// connection is created. A CLI destination has no delivery_policy in the API +// schema: the API accepts the request and discards the policy. +func TestConnectionDestinationDeliveryPolicyRejectedForCLIType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + }{ + {"rate limit", []string{"--destination-rate-limit", "100", "--destination-rate-limit-period", "minute"}}, + {"delivery group", []string{ + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + }}, + {"delivery group with overrides", []string{ + "--destination-delivery-group-key", "body.customer_id", + "--destination-delivery-group-rate", "10", + "--destination-delivery-group-rate-period", "second", + "--destination-delivery-group-overrides", connectionSampleOverridesJSON, + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-cli-" + timestamp + + args := append([]string{ + "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dgcli-" + timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dgcli-" + timestamp, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "delivery-policy flags must be refused for CLI destinations") + assert.Contains(t, stdout+stderr, "CLI destinations", + "the error should name CLI destinations as the reason") + + requireNoConnection(t, cli, connName) + }) + } +} + +// TestConnectionDestinationDeliveryPolicyPartialFlagsRejected asserts that an +// incomplete delivery-policy flag set is refused before any API call, and that +// nothing is created. +func TestConnectionDestinationDeliveryPolicyPartialFlagsRejected(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + wantError string + }{ + { + name: "group rate without key", + flags: []string{"--destination-delivery-group-rate", "10", "--destination-delivery-group-rate-period", "second"}, + wantError: "--destination-delivery-group-key", + }, + { + name: "group rate without rate period", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate", "10"}, + wantError: "--destination-delivery-group-rate-period", + }, + { + name: "overrides without a key", + flags: []string{"--destination-delivery-group-overrides", connectionSampleOverridesJSON}, + wantError: "--destination-delivery-group-key", + }, + { + name: "rate limit without period", + flags: []string{"--destination-rate-limit", "100"}, + wantError: "--destination-rate-limit-period", + }, + { + name: "negative group rate", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate=-5", "--destination-delivery-group-rate-period", "second"}, + wantError: "--destination-delivery-group-rate must be a positive integer", + }, + { + name: "negative rate limit", + flags: []string{"--destination-rate-limit=-5", "--destination-rate-limit-period", "minute"}, + wantError: "--destination-rate-limit must be a positive integer", + }, + { + name: "malformed overrides JSON", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate", "10", "--destination-delivery-group-rate-period", "second", "--destination-delivery-group-overrides", `{"cust_1": {"rate": 5`}, + wantError: "--destination-delivery-group-overrides must be a valid JSON object", + }, + { + name: "overrides as a JSON array", + flags: []string{"--destination-delivery-group-key", "body.customer_id", "--destination-delivery-group-rate", "10", "--destination-delivery-group-rate-period", "second", "--destination-delivery-group-overrides", `[{"rate":5}]`}, + wantError: "--destination-delivery-group-overrides must be a valid JSON object", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + connName := "test-conn-dg-partial-" + timestamp + + args := append([]string{ + "gateway", "connection", "create", + "--name", connName, + "--source-name", "test-src-dgp-" + timestamp, + "--source-type", "WEBHOOK", + "--destination-name", "test-dst-dgp-" + timestamp, + "--destination-type", "HTTP", + "--destination-url", "https://example.com/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "an incomplete or invalid delivery-policy flag set must be refused") + assert.Contains(t, stdout+stderr, tc.wantError, + "the error should name the offending flag") + + requireNoConnection(t, cli, connName) + }) + } +} diff --git a/test/acceptance/connection_test.go b/test/acceptance/connection_test.go index 71b80c12..8f6a4ea4 100644 --- a/test/acceptance/connection_test.go +++ b/test/acceptance/connection_test.go @@ -1601,12 +1601,14 @@ func TestConnectionWithRateLimiting(t *testing.T) { require.NotNil(t, getConn.Destination, "Connection should have a destination") if config, ok := getConn.Destination.Config.(map[string]interface{}); ok { - rateLimit, hasRateLimit := config["rate_limit"].(float64) - require.True(t, hasRateLimit, "Rate limit should be present in destination config") + deliveryPolicy, hasPolicy := config["delivery_policy"].(map[string]interface{}) + require.True(t, hasPolicy, "Delivery policy should be present in destination config") + rateLimit, hasRateLimit := deliveryPolicy["rate"].(float64) + require.True(t, hasRateLimit, "Rate limit should be present in delivery policy") assert.Equal(t, float64(100), rateLimit, "Rate limit should be 100") - period, hasPeriod := config["rate_limit_period"].(string) - require.True(t, hasPeriod, "Rate limit period should be present in destination config") + period, hasPeriod := deliveryPolicy["period"].(string) + require.True(t, hasPeriod, "Rate limit period should be present in delivery policy") assert.Equal(t, "second", period, "Rate limit period should be second") } else { t.Fatal("Destination config should be present") @@ -1647,12 +1649,14 @@ func TestConnectionWithRateLimiting(t *testing.T) { require.NotNil(t, getConn.Destination, "Connection should have a destination") if config, ok := getConn.Destination.Config.(map[string]interface{}); ok { - rateLimit, hasRateLimit := config["rate_limit"].(float64) - require.True(t, hasRateLimit, "Rate limit should be present in destination config") + deliveryPolicy, hasPolicy := config["delivery_policy"].(map[string]interface{}) + require.True(t, hasPolicy, "Delivery policy should be present in destination config") + rateLimit, hasRateLimit := deliveryPolicy["rate"].(float64) + require.True(t, hasRateLimit, "Rate limit should be present in delivery policy") assert.Equal(t, float64(1000), rateLimit, "Rate limit should be 1000") - period, hasPeriod := config["rate_limit_period"].(string) - require.True(t, hasPeriod, "Rate limit period should be present in destination config") + period, hasPeriod := deliveryPolicy["period"].(string) + require.True(t, hasPeriod, "Rate limit period should be present in delivery policy") assert.Equal(t, "minute", period, "Rate limit period should be minute") } else { t.Fatal("Destination config should be present") @@ -1692,12 +1696,14 @@ func TestConnectionWithRateLimiting(t *testing.T) { require.NotNil(t, getConn.Destination, "Connection should have a destination") if config, ok := getConn.Destination.Config.(map[string]interface{}); ok { - rateLimit, hasRateLimit := config["rate_limit"].(float64) - require.True(t, hasRateLimit, "Rate limit should be present in destination config") + deliveryPolicy, hasPolicy := config["delivery_policy"].(map[string]interface{}) + require.True(t, hasPolicy, "Delivery policy should be present in destination config") + rateLimit, hasRateLimit := deliveryPolicy["rate"].(float64) + require.True(t, hasRateLimit, "Rate limit should be present in delivery policy") assert.Equal(t, float64(10), rateLimit, "Rate limit should be 10") - period, hasPeriod := config["rate_limit_period"].(string) - require.True(t, hasPeriod, "Rate limit period should be present in destination config") + period, hasPeriod := deliveryPolicy["period"].(string) + require.True(t, hasPeriod, "Rate limit period should be present in delivery policy") assert.Equal(t, "concurrent", period, "Rate limit period should be concurrent") } else { t.Fatal("Destination config should be present") diff --git a/test/acceptance/destination_config_json_test.go b/test/acceptance/destination_config_json_test.go index 0fa2a44e..0d3f5b7b 100644 --- a/test/acceptance/destination_config_json_test.go +++ b/test/acceptance/destination_config_json_test.go @@ -55,7 +55,7 @@ func TestDestinationCreateWithConfigJSONExactValues(t *testing.T) { err := cli.RunJSON(&resp, "gateway", "destination", "create", "--name", name, "--type", "HTTP", - "--config", `{"url":"https://api.example.com/hooks","rate_limit":100,"rate_limit_period":"second"}`, + "--config", `{"url":"https://api.example.com/hooks","delivery_policy":{"rate":100,"period":"second"}}`, ) require.NoError(t, err, "Should create destination with rate limit config") @@ -66,10 +66,10 @@ func TestDestinationCreateWithConfigJSONExactValues(t *testing.T) { config, ok := resp["config"].(map[string]interface{}) require.True(t, ok, "Expected config object in response") assert.Equal(t, "https://api.example.com/hooks", config["url"]) - assert.Equal(t, float64(100), config["rate_limit"], - "rate_limit should be exactly 100") - assert.Equal(t, "second", config["rate_limit_period"], - "rate_limit_period should be 'second'") + deliveryPolicy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok, "Expected delivery_policy object in config") + assert.Equal(t, float64(100), deliveryPolicy["rate"], "rate should be exactly 100") + assert.Equal(t, "second", deliveryPolicy["period"], "period should be 'second'") }) } diff --git a/test/acceptance/destination_delivery_group_test.go b/test/acceptance/destination_delivery_group_test.go new file mode 100644 index 00000000..525eadd0 --- /dev/null +++ b/test/acceptance/destination_delivery_group_test.go @@ -0,0 +1,457 @@ +//go:build destination + +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Delivery groups are the headline feature of v2.6.0. A destination carries a +// config.delivery_policy with a destination-level rate/period and a nested +// groups object (key, rate, rate_period, overrides), driven by +// --delivery-group-key / --delivery-group-rate / --delivery-group-rate-period / +// --delivery-group-overrides on `gateway destination`. +// +// These tests exercise the real API, because the defects worth guarding against +// are all about what the API ends up storing, not about what the CLI builds in +// memory. + +// destinationDeliveryPolicy extracts config.delivery_policy from a destination +// response body. +func destinationDeliveryPolicy(t *testing.T, resp map[string]interface{}) map[string]interface{} { + t.Helper() + config, ok := resp["config"].(map[string]interface{}) + require.True(t, ok, "expected config object on destination, got %T", resp["config"]) + policy, ok := config["delivery_policy"].(map[string]interface{}) + require.True(t, ok, "expected config.delivery_policy object, got %v", config["delivery_policy"]) + return policy +} + +// destinationDeliveryGroups extracts config.delivery_policy.groups. +func destinationDeliveryGroups(t *testing.T, resp map[string]interface{}) map[string]interface{} { + t.Helper() + groups, ok := destinationDeliveryPolicy(t, resp)["groups"].(map[string]interface{}) + require.True(t, ok, "expected config.delivery_policy.groups object") + return groups +} + +// requireNoDestination asserts that no destination exists under the given name. +// Every rejection test uses this: refusing the command is only half the +// contract, the other half is that nothing was created before the refusal. +func requireNoDestination(t *testing.T, cli *CLIRunner, name string) { + t.Helper() + stdout, stderr, err := cli.Run("gateway", "destination", "get", name) + if err == nil { + // Something was created despite the rejection. Clean it up so the + // failure does not also leak a resource (#362), then fail. + var dst Destination + if jsonErr := cli.RunJSON(&dst, "gateway", "destination", "get", name); jsonErr == nil && dst.ID != "" { + deleteDestination(t, cli, dst.ID) + } + t.Fatalf("rejected command must not create a destination, but %q exists: %s", name, stdout) + } + assert.Contains(t, stdout+stderr, "no destination found", + "expected a not-found error for %q", name) +} + +// sampleOverrides is the overrides object used across these tests. Two entries, +// so a test cannot pass by preserving only the first. +func sampleOverrides() map[string]interface{} { + return map[string]interface{}{ + "cust_1": map[string]interface{}{"rate": float64(5), "rate_period": "minute"}, + "cust_2": map[string]interface{}{"rate": float64(50), "rate_period": "second"}, + } +} + +const sampleOverridesJSON = `{"cust_1":{"rate":5,"rate_period":"minute"},"cust_2":{"rate":50,"rate_period":"second"}}` + +// TestDestinationCreateWithDeliveryGroup creates a destination with a +// destination-level rate limit and a full delivery-group triple plus +// overrides, then reads it back with --output json and asserts the stored +// delivery_policy matches exactly, nested overrides included. +func TestDestinationCreateWithDeliveryGroup(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dg-create-" + generateTimestamp() + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--rate-limit", "100", + "--rate-limit-period", "minute", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + ) + require.NoError(t, err, "Should create destination with a delivery group") + + dstID, ok := created["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + // Read back rather than trusting the create response. + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "destination", "get", dstID) + require.NoError(t, err, "Should read the destination back") + + policy := destinationDeliveryPolicy(t, fetched) + assert.Equal(t, float64(100), policy["rate"], "destination-level rate should be stored") + assert.Equal(t, "minute", policy["period"], "destination-level period should be stored") + + assert.Equal(t, map[string]interface{}{ + "key": "body.customer_id", + "rate": float64(10), + "rate_period": "second", + "overrides": sampleOverrides(), + }, destinationDeliveryGroups(t, fetched), + "stored delivery_policy.groups must match the flags exactly, overrides included") + + t.Logf("Created destination %s (ID: %s) with delivery group overrides", name, dstID) +} + +// TestDestinationUpsertPreservesDeliveryGroupOverrides is the regression guard +// for #393. The API replaces delivery_policy.groups wholesale, and the CLI +// requires --delivery-group-key and --delivery-group-rate-period whenever +// --delivery-group-rate is given, so "just bump the group rate" always sends a +// full groups object. Before the fix that silently destroyed the stored +// overrides. Only unit tests covered this; nothing exercised the real API. +func TestDestinationUpsertPreservesDeliveryGroupOverrides(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dg-upsert-" + generateTimestamp() + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + ) + require.NoError(t, err, "Should create destination carrying overrides") + + dstID, ok := created["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + require.Equal(t, sampleOverrides(), destinationDeliveryGroups(t, created)["overrides"], + "precondition: the destination must be created with overrides") + + // Change ONLY the group rate. The key and rate-period have to be repeated + // because the CLI refuses a partial group triple. + var upserted map[string]interface{} + err = cli.RunJSON(&upserted, "gateway", "destination", "upsert", name, + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "30", + "--delivery-group-rate-period", "second", + ) + require.NoError(t, err, "Should upsert the group rate") + + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "destination", "get", dstID) + require.NoError(t, err, "Should read the destination back after upsert") + + groups := destinationDeliveryGroups(t, fetched) + assert.Equal(t, float64(30), groups["rate"], "the requested rate change must apply") + assert.Equal(t, "body.customer_id", groups["key"], "the group key must be unchanged") + assert.Equal(t, "second", groups["rate_period"], "the group rate period must be unchanged") + assert.Equal(t, sampleOverrides(), groups["overrides"], + "bumping the group rate must not destroy the stored per-group overrides (#393)") +} + +// TestDestinationUpdatePreservesDeliveryGroupOverrides is the same #393 +// contract on `gateway destination update`. +// +// KNOWN FAILURE at the time of writing. preserveDeliveryGroupOverrides is +// called from `destination upsert` and `connection upsert`, but not from +// `destination update`, so bumping only the group rate through `update` still +// destroys the stored overrides — the identical silent data loss, on a sibling +// command. Verified against the live API: the groups object comes back with +// rate 30 and no overrides key at all. +// +// The behaviour a user gets should not depend on which of two commands they +// reach for, so this asserts the same thing the upsert test does. +func TestDestinationUpdatePreservesDeliveryGroupOverrides(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dg-update-" + generateTimestamp() + + var created map[string]interface{} + err := cli.RunJSON(&created, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + ) + require.NoError(t, err, "Should create destination carrying overrides") + + dstID, ok := created["id"].(string) + require.True(t, ok && dstID != "", "Expected destination ID") + t.Cleanup(func() { deleteDestination(t, cli, dstID) }) + + require.Equal(t, sampleOverrides(), destinationDeliveryGroups(t, created)["overrides"], + "precondition: the destination must be created with overrides") + + // Change ONLY the group rate. + var updated map[string]interface{} + err = cli.RunJSON(&updated, "gateway", "destination", "update", dstID, + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "30", + "--delivery-group-rate-period", "second", + ) + require.NoError(t, err, "Should update the group rate") + + var fetched map[string]interface{} + err = cli.RunJSON(&fetched, "gateway", "destination", "get", dstID) + require.NoError(t, err, "Should read the destination back after update") + + groups := destinationDeliveryGroups(t, fetched) + assert.Equal(t, float64(30), groups["rate"], "the requested rate change must apply") + assert.Equal(t, sampleOverrides(), groups["overrides"], + "bumping the group rate via `destination update` must not destroy the stored "+ + "per-group overrides; the #393 fix reached upsert but not update") +} + +// TestDestinationDeliveryPolicyRejectedForCLIType asserts that delivery-policy +// flags are refused on a CLI destination. A CLI destination carries no +// delivery_policy in the API schema: the API accepts the request and silently +// discards the policy, so the CLI has to refuse rather than let the flags look +// applied. Nothing may be created. +func TestDestinationDeliveryPolicyRejectedForCLIType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + }{ + {"rate limit", []string{"--rate-limit", "100", "--rate-limit-period", "minute"}}, + {"delivery group", []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + }}, + {"delivery group with overrides", []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", sampleOverridesJSON, + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-cli-" + generateTimestamp() + + args := append([]string{ + "gateway", "destination", "create", + "--name", name, + "--type", "CLI", + "--cli-path", "/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "delivery-policy flags must be refused for CLI destinations") + assert.Contains(t, stdout+stderr, "CLI destinations", + "the error should name CLI destinations as the reason") + + requireNoDestination(t, cli, name) + }) + } +} + +// TestDestinationDeliveryPolicyPartialFlagsRejected asserts that an incomplete +// set of delivery-policy flags is refused before any API call, and that nothing +// is created. Each of these silently dropped the value at some point. +func TestDestinationDeliveryPolicyPartialFlagsRejected(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + wantError string + }{ + { + name: "group rate without key", + flags: []string{"--delivery-group-rate", "10", "--delivery-group-rate-period", "second"}, + wantError: "--delivery-group-key", + }, + { + name: "group rate without rate period", + flags: []string{"--delivery-group-key", "body.customer_id", "--delivery-group-rate", "10"}, + wantError: "--delivery-group-rate-period", + }, + { + name: "group key without rate", + flags: []string{"--delivery-group-key", "body.customer_id", "--delivery-group-rate-period", "second"}, + wantError: "--delivery-group-rate", + }, + { + name: "overrides without a key", + flags: []string{"--delivery-group-overrides", sampleOverridesJSON}, + wantError: "--delivery-group-key", + }, + { + name: "rate limit without period", + flags: []string{"--rate-limit", "100"}, + wantError: "--rate-limit-period", + }, + { + name: "rate limit period without rate", + flags: []string{"--rate-limit-period", "minute"}, + wantError: "--rate-limit", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-partial-" + generateTimestamp() + + args := append([]string{ + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "an incomplete delivery-policy flag set must be refused") + assert.Contains(t, stdout+stderr, tc.wantError, + "the error should name the missing flag") + + requireNoDestination(t, cli, name) + }) + } +} + +// TestDestinationDeliveryPolicyRejectsNonPositiveRates asserts that zero and +// negative rates are refused client-side rather than dropped. Testing only +// `rate > 0` once let --rate-limit=-5 fall through both guards: the command +// succeeded having quietly ignored the value. +func TestDestinationDeliveryPolicyRejectsNonPositiveRates(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + flags []string + wantError string + }{ + { + name: "negative rate limit", + flags: []string{"--rate-limit=-5", "--rate-limit-period", "minute"}, + wantError: "--rate-limit must be a positive integer", + }, + { + name: "zero rate limit", + flags: []string{"--rate-limit", "0", "--rate-limit-period", "minute"}, + wantError: "--rate-limit must be a positive integer", + }, + { + name: "negative group rate", + flags: []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate=-5", + "--delivery-group-rate-period", "second", + }, + wantError: "--delivery-group-rate must be a positive integer", + }, + { + name: "zero group rate", + flags: []string{ + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "0", + "--delivery-group-rate-period", "second", + }, + wantError: "--delivery-group-rate must be a positive integer", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-rate-" + generateTimestamp() + + args := append([]string{ + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + }, tc.flags...) + + stdout, stderr, err := cli.Run(args...) + require.Error(t, err, "a non-positive rate must be refused, not silently dropped") + assert.Contains(t, stdout+stderr, tc.wantError) + + requireNoDestination(t, cli, name) + }) + } +} + +// TestDestinationDeliveryGroupOverridesMustBeJSONObject asserts that +// --delivery-group-overrides is parsed and rejected when it is not a JSON +// object. A JSON array parses as valid JSON but not as the object the API +// expects, so it needs its own case. +func TestDestinationDeliveryGroupOverridesMustBeJSONObject(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cases := []struct { + name string + overrides string + }{ + {"malformed JSON", `{"cust_1": {"rate": 5`}, + {"not JSON at all", `cust_1=5`}, + {"JSON array instead of object", `[{"rate":5,"rate_period":"minute"}]`}, + {"JSON null", `null`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli := NewCLIRunner(t) + name := "test-dst-dg-badjson-" + generateTimestamp() + + stdout, stderr, err := cli.Run( + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + "--delivery-group-key", "body.customer_id", + "--delivery-group-rate", "10", + "--delivery-group-rate-period", "second", + "--delivery-group-overrides", tc.overrides, + ) + require.Error(t, err, "invalid overrides JSON must be refused") + assert.Contains(t, stdout+stderr, "--delivery-group-overrides must be a valid JSON object") + + requireNoDestination(t, cli, name) + }) + } +} diff --git a/test/acceptance/guest_login_acceptance_test.go b/test/acceptance/guest_login_acceptance_test.go index 893f7c08..3bffe268 100644 --- a/test/acceptance/guest_login_acceptance_test.go +++ b/test/acceptance/guest_login_acceptance_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "io" "net/http" "net/http/httptest" @@ -69,7 +70,7 @@ func newGuestLoginMock(t *testing.T, assertBody func(map[string]interface{}), br var payload map[string]interface{} require.NoError(t, json.Unmarshal(raw, &payload)) assertBody(payload) - pollURL := serverURL + "/2025-07-01/cli-auth/poll?key=pollkey" + pollURL := serverURL + hookdeck.APIPathPrefix + "/cli-auth/poll?key=pollkey" respBody, encErr := json.Marshal(map[string]string{ "browser_url": browserURL, "poll_url": pollURL, @@ -81,7 +82,7 @@ func newGuestLoginMock(t *testing.T, assertBody func(map[string]interface{}), br "claimed": true, "key": "hk_test_guest_claimed", "team_id": "tm_guest", - "team_mode": "console", + "team_type": "console", "team_name": "Guest Sandbox", "user_name": "Guest", "user_email": "guest@example.com", @@ -143,7 +144,7 @@ func TestGuestLoginDefaultClaimGuestAcceptance(t *testing.T) { "organization_id": "org_guest", "team_id": "tm_guest", "team_name_no_org": "Guest Sandbox", - "team_mode": "console", + "team_type": "console", "client_id": "cl_guest", }) require.NoError(t, encErr) diff --git a/test/acceptance/helpers_retry_test.go b/test/acceptance/helpers_retry_test.go index d770d109..d1aeefb5 100644 --- a/test/acceptance/helpers_retry_test.go +++ b/test/acceptance/helpers_retry_test.go @@ -5,6 +5,7 @@ package acceptance import ( "errors" "fmt" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "net/http" "net/http/httptest" "testing" @@ -58,7 +59,7 @@ func TestRunWithHTTP502RetryResetsRecordingProxy(t *testing.T) { // X-Hookdeck-CLI-Telemetry header with the given invocation_id and command_path. func makeProxiedTelemetryRequest(t *testing.T, proxyURL, invocationID, commandPath string) { t.Helper() - req, err := http.NewRequest(http.MethodGet, proxyURL+"/2025-07-01/connections", nil) + req, err := http.NewRequest(http.MethodGet, proxyURL+hookdeck.APIPathPrefix+"/connections", nil) require.NoError(t, err) req.Header.Set("X-Hookdeck-CLI-Telemetry", fmt.Sprintf(`{"command_path":%q,"invocation_id":%q}`, commandPath, invocationID)) 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/login_auth_acceptance_test.go b/test/acceptance/login_auth_acceptance_test.go index 9ee3ee67..01654395 100644 --- a/test/acceptance/login_auth_acceptance_test.go +++ b/test/acceptance/login_auth_acceptance_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -59,7 +60,7 @@ api_key = "hk_test_stale_accept01" "claimed": true, "key": "hk_test_newkey_accept01", "team_id": "tm_accept", - "team_mode": "gateway", + "team_type": "event_gateway", "team_name": "AcceptProj", "user_name": "Accept", "user_email": "accept@example.com", @@ -96,9 +97,48 @@ api_key = "hk_test_stale_accept01" cmd.Stdout = &stdout cmd.Stderr = &stderr err = cmd.Run() + + // SSH_CONNECTION is set above on purpose: waitForLoginSession prints the URL + // and polls in that case, without ever reading stdin, so the flow completes + // with no terminal and a human elsewhere. That is the path this test covers. + // + // It briefly did not. A guard added for the CI hang refused on "no terminal" + // alone, which took this branch out too, and this test was then rewritten to + // assert the refusal - encoding the regression rather than catching it. The + // guard now only refuses where nobody can act; see + // TestLogin_rejectedKeyHeadlessFailsFast for that case. require.NoError(t, err, "stdout=%q stderr=%q", stdout.String(), stderr.String()) require.Contains(t, stdout.String(), "no longer valid", "user should see stale-key message") require.Equal(t, 1, pollHits, "mock should see exactly one poll after cli-auth") + +} + +// TestCIWritesTheProjectTypeAcceptance is the end-to-end check that the +// 2026-09-01 project type survives the whole round trip: API response, to +// profile, to config file. Everything else about the rename is covered by unit +// tests against mocks this repo also writes, so this is the only place the +// persisted value is verified against a real CLI run. +// +// It goes through `ci` rather than `login` because `ci` is the path that works +// without a terminal, and CI has none. +func TestCIWritesTheProjectTypeAcceptance(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + configPath := filepath.Join(t.TempDir(), "config.toml") + // The runner authenticates with `ci`, which is what writes the config. + NewCLIRunnerWithConfigPath(t, configPath) + + written, err := os.ReadFile(configPath) + require.NoError(t, err) + + assert.Contains(t, string(written), "project_type = 'Gateway'", + "the config is shared with older CLIs, which only understand the label") + assert.Contains(t, string(written), "project_mode = 'inbound'", + "the legacy mode is still written for older CLIs") + assert.NotContains(t, string(written), "project_product", + "the short-lived product key must not be written") } // TestCIFailsFastWithInvalidAPIKeyAcceptance verifies hookdeck ci does not enter the @@ -120,30 +160,59 @@ func TestCIFailsFastWithInvalidAPIKeyAcceptance(t *testing.T) { [default] `), 0o600)) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + // Generous enough for every retry below, including `go run` compiling each + // time: a deadline sized for one attempt would cut the retries short and + // reintroduce the flake it is there to absorb. + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) defer cancel() invalidKey := "hk_test_ci_invalid_accept01" // valid shape, not a real key - cmd := exec.CommandContext(ctx, "go", []string{"run", mainGo, + args := []string{"run", mainGo, "--hookdeck-config", configPath, "--log-level", "error", "ci", "--api-key", invalidKey, - }...) - cmd.Dir = projectRoot + } env := appendEnvOverride(os.Environ(), "HOOKDECK_CONFIG_FILE", configPath) env = appendEnvOverride(env, "HOOKDECK_CLI_TELEMETRY_DISABLED", "1") - cmd.Env = env - start := time.Now() + // This test asserts on the shape of an authentication failure, so a + // transport failure is not a result it can read. POST /cli-auth/ci answers + // 502 often enough to have reddened this build three times in one day, and + // a gateway error is not an auth outcome at all. Retry it the way + // CLIRunner.Run does - this test builds its own exec.Cmd, so it does not go + // through that path and inherited no retry. var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err = cmd.Run() + var elapsed time.Duration + for attempt := 1; attempt <= acceptance502MaxAttempts; attempt++ { + stdout.Reset() + stderr.Reset() + + run := exec.CommandContext(ctx, "go", args...) + run.Dir = projectRoot + run.Env = env + run.Stdout = &stdout + run.Stderr = &stderr + + start := time.Now() + err = run.Run() + elapsed = time.Since(start) + + if !combinedOutputLooksLikeHTTP502(stdout.String(), stderr.String()) { + break + } + if attempt < acceptance502MaxAttempts { + t.Logf("acceptance: Hookdeck API transient HTTP 502 on cli-auth/ci; retrying (attempt %d/%d)", attempt, acceptance502MaxAttempts) + time.Sleep(acceptance502RetryDelay) + } + } + require.Error(t, err, "ci with bogus API key must fail") - elapsed := time.Since(start) require.Less(t, elapsed, 30*time.Second, "ci should fail quickly without waiting for interactive login; took %v", elapsed) combined := stdout.String() + "\n" + stderr.String() + if combinedOutputLooksLikeHTTP502(stdout.String(), stderr.String()) { + t.Skipf("Hookdeck API returned HTTP 502 on every attempt; this test cannot observe an auth failure through a gateway error") + } require.Contains(t, combined, "Authentication failed", "expected friendly auth message; stdout=%q stderr=%q", stdout.String(), stderr.String()) diff --git a/test/acceptance/metrics_test.go b/test/acceptance/metrics_test.go index 87cd77ac..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) { @@ -269,13 +299,25 @@ func TestMetricsAttemptsWithMeasuresAndDimensions(t *testing.T) { assert.NotEmpty(t, stdout) } -func TestMetricsAttemptsWithConnectionID(t *testing.T) { +// TestMetricsAttemptsRejectsConnectionID replaces a test that asserted +// `metrics attempts --connection-id` succeeds. It did succeed, but only because +// the attempts endpoint has no webhook_id filter and the API drops keys it does +// not recognize: the call returned unfiltered totals under a flag that said +// otherwise. Measured against production over 14 days - attempts returned 150 +// with and without a bogus --connection-id, while events returned 0 against 145 +// for the same flag, which that endpoint does honour. +// +// The flag is no longer offered there, so the contract to hold is that it is +// refused rather than silently ignored. +func TestMetricsAttemptsRejectsConnectionID(t *testing.T) { if testing.Short() { t.Skip("Skipping acceptance test in short mode") } cli := NewCLIRunner(t) - stdout := cli.RunExpectSuccess(append(metricsArgs("attempts"), "--measures", "count", "--connection-id", "web_placeholder")...) - assert.NotEmpty(t, stdout) + stdout, stderr, err := cli.Run(append(metricsArgs("attempts"), "--measures", "count", "--connection-id", "web_placeholder")...) + require.Error(t, err, "attempts ignores connection-id, so the flag must not be accepted") + // cobra writes the flag error to stdout, not stderr. + assert.Contains(t, stdout+stderr, "unknown flag") } func TestMetricsAttemptsWithGranularity(t *testing.T) { diff --git a/test/acceptance/project_use_test.go b/test/acceptance/project_use_test.go index 4e3ff77a..efc1ffd2 100644 --- a/test/acceptance/project_use_test.go +++ b/test/acceptance/project_use_test.go @@ -23,10 +23,10 @@ import ( // - TestLocalConfigHelpers (no API calls, tests helper functions) // // Manual tests (in project_use_manual_test.go): -// - TestProjectUseLocalCreatesConfig (requires /teams endpoint access) -// - TestProjectUseSmartDefault (requires /teams endpoint access) -// - TestProjectUseLocalCreateDirectory (requires /teams endpoint access) -// - TestProjectUseLocalSecurityWarning (requires /teams endpoint access) +// - TestProjectUseLocalCreatesConfig (requires /projects endpoint access) +// - TestProjectUseSmartDefault (requires /projects endpoint access) +// - TestProjectUseLocalCreateDirectory (requires /projects endpoint access) +// - TestProjectUseLocalSecurityWarning (requires /projects endpoint access) // // To run manual tests: go test -tags=manual -v ./test/acceptance/ @@ -164,9 +164,13 @@ func TestProjectListShowsType(t *testing.T) { t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") } cli := NewCLIRunnerWithKey(t, cliKey) - stdout := cli.RunExpectSuccess("project", "list") - // Default output format: "Org / Project (current?) | Type" - assert.Contains(t, stdout, "|", "project list should show type separator") + stdout, _, err := cli.Run("project", "list") + // Deliberately not echoing stdout into any failure message: this listing is + // every project the credential can see, and Actions logs on a public repo + // are world-readable. Assert on derived values instead. + require.NoError(t, err, "project list should succeed with an account-wide CLI key") + + assert.True(t, strings.Contains(stdout, "|"), "project list should show the type separator") assert.True(t, strings.Contains(stdout, "Gateway") || strings.Contains(stdout, "Outpost") || strings.Contains(stdout, "Console"), "project list should show at least one project type (Gateway, Outpost, or Console)") @@ -183,7 +187,9 @@ func TestProjectListJSONOutput(t *testing.T) { t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") } cli := NewCLIRunnerWithKey(t, cliKey) - stdout := cli.RunExpectSuccess("project", "list", "--output", "json") + stdout, _, err := cli.Run("project", "list", "--output", "json") + // As above: never put this payload in a failure message. + require.NoError(t, err, "project list --output json should succeed") var list []struct { Id string `json:"id"` Org string `json:"org"` @@ -191,8 +197,8 @@ func TestProjectListJSONOutput(t *testing.T) { Type string `json:"type"` Current bool `json:"current"` } - err := json.Unmarshal([]byte(stdout), &list) - require.NoError(t, err, "project list --output json should return valid JSON array") + require.NoError(t, json.Unmarshal([]byte(stdout), &list), + "project list --output json should return valid JSON array") for i, item := range list { assert.NotEmpty(t, item.Id, "item %d should have id", i) assert.NotEmpty(t, item.Type, "item %d should have type", i) @@ -226,7 +232,9 @@ func TestProjectListFilterByType(t *testing.T) { t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") } cli := NewCLIRunnerWithKey(t, cliKey) - stdout := cli.RunExpectSuccess("project", "list", "--type", "gateway", "--output", "json") + stdout, _, err := cli.Run("project", "list", "--type", "gateway", "--output", "json") + // No payload in the failure message: see TestProjectListShowsType. + require.NoError(t, err, "project list should succeed with an account-wide CLI key") var list []struct { Id string `json:"id"` Org string `json:"org"` @@ -234,7 +242,7 @@ func TestProjectListFilterByType(t *testing.T) { Type string `json:"type"` Current bool `json:"current"` } - err := json.Unmarshal([]byte(stdout), &list) + err = json.Unmarshal([]byte(stdout), &list) require.NoError(t, err, "project list --type gateway --output json should return valid JSON array") for i, item := range list { assert.Equal(t, "gateway", item.Type, "item %d should have type gateway when filtering by --type gateway", i) @@ -253,7 +261,9 @@ func TestProjectListFilterByOrgProject(t *testing.T) { } cli := NewCLIRunnerWithKey(t, cliKey) // Get full list first to derive a substring that matches at least one project - full := cli.RunExpectSuccess("project", "list", "--output", "json") + full, _, err := cli.Run("project", "list", "--output", "json") + // No payload in the failure message: see TestProjectListShowsType. + require.NoError(t, err, "project list should succeed with an account-wide CLI key") var fullList []struct { Org string `json:"org"` Project string `json:"project"` @@ -268,7 +278,9 @@ func TestProjectListFilterByOrgProject(t *testing.T) { t.Skip("First project has no org; skipping filter test") } substring := string([]rune(firstOrg)[0]) - stdout := cli.RunExpectSuccess("project", "list", substring, "--output", "json") + stdout, _, err := cli.Run("project", "list", substring, "--output", "json") + // No payload in the failure message: see TestProjectListShowsType. + require.NoError(t, err, "project list should succeed with an account-wide CLI key") var filtered []struct { Id string `json:"id"` Org string `json:"org"` @@ -276,7 +288,7 @@ func TestProjectListFilterByOrgProject(t *testing.T) { Type string `json:"type"` Current bool `json:"current"` } - err := json.Unmarshal([]byte(stdout), &filtered) + err = json.Unmarshal([]byte(stdout), &filtered) require.NoError(t, err, "project list with org substring should return valid JSON array") for i, item := range filtered { assert.Contains(t, strings.ToLower(item.Org), strings.ToLower(substring), @@ -296,7 +308,9 @@ func TestProjectListFilterByOrgAndProject(t *testing.T) { t.Skip("Skipping project list test: HOOKDECK_CLI_TESTING_CLI_KEY must be set (CLI key required for listing projects; API and CI keys cannot list or switch projects)") } cli := NewCLIRunnerWithKey(t, cliKey) - full := cli.RunExpectSuccess("project", "list", "--output", "json") + full, _, err := cli.Run("project", "list", "--output", "json") + // No payload in the failure message: see TestProjectListShowsType. + require.NoError(t, err, "project list should succeed with an account-wide CLI key") var fullList []struct { Org string `json:"org"` Project string `json:"project"` @@ -314,13 +328,15 @@ func TestProjectListFilterByOrgAndProject(t *testing.T) { // Use first character of each so filter matches at least one project orgChar := string([]rune(orgSub)[0]) projChar := string([]rune(projSub)[0]) - stdout := cli.RunExpectSuccess("project", "list", orgChar, projChar, "--output", "json") + stdout, _, err := cli.Run("project", "list", orgChar, projChar, "--output", "json") + // No payload in the failure message: see TestProjectListShowsType. + require.NoError(t, err, "project list should succeed with an account-wide CLI key") var filtered []struct { Org string `json:"org"` Project string `json:"project"` Type string `json:"type"` } - err := json.Unmarshal([]byte(stdout), &filtered) + err = json.Unmarshal([]byte(stdout), &filtered) require.NoError(t, err, "project list with org and project substrings should return valid JSON array") for i, item := range filtered { assert.Contains(t, strings.ToLower(item.Org), strings.ToLower(orgChar), @@ -347,4 +363,11 @@ func TestProjectListFailsWithCIKeyAcceptance(t *testing.T) { assert.NotContains(t, combined, "Fatal Error") assert.NotContains(t, combined, "status=500") assert.Contains(t, combined, "single project") + // The reason alone is not actionable: the message has to name the command + // that fixes it, because the two kinds of CLI key are not visible to the + // user otherwise. `hookdeck ci` issues a project-scoped key with no user and + // core rejects it for this endpoint; `hookdeck login` issues an account-wide + // one that works. + assert.Contains(t, combined, "hookdeck login", + "the error should tell the user how to get an account-wide key") } diff --git a/test/acceptance/telemetry_test.go b/test/acceptance/telemetry_test.go index ed1a562d..8309e6e0 100644 --- a/test/acceptance/telemetry_test.go +++ b/test/acceptance/telemetry_test.go @@ -52,7 +52,7 @@ func logRecordedTelemetry(t *testing.T, recorded []RecordedRequest) { } // TestTelemetryLoginProxy verifies what we send when we run "hookdeck login --api-key": -// exactly one API call (GET /2025-07-01/cli-auth/validate) with one command_path and one +// exactly one API call (GET /2026-09-01/cli-auth/validate) with one command_path and one // invocation_id. Uses the same proxy approach as other telemetry tests (record then forward // to the real API). Requires HOOKDECK_CLI_TESTING_CLI_KEY (the validate endpoint accepts // CLI keys from interactive login; API/CI keys may return 401). @@ -761,7 +761,7 @@ func TestTelemetryGatewaySourceUpsertProxy(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } runTelemetryProxyTestSuccess(t, - []string{"gateway", "source", "upsert", "telemetry-src-upsert-"+generateTimestamp(), "--type", "WEBHOOK"}, + []string{"gateway", "source", "upsert", "telemetry-src-upsert-" + generateTimestamp(), "--type", "WEBHOOK"}, "hookdeck gateway source upsert") } func TestTelemetryGatewaySourceCountProxy(t *testing.T) { @@ -875,7 +875,7 @@ func TestTelemetryGatewayDestinationUpsertProxy(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } runTelemetryProxyTestSuccess(t, - []string{"gateway", "destination", "upsert", "telemetry-dst-upsert-"+generateTimestamp(), "--type", "HTTP", "--url", "https://example.com"}, + []string{"gateway", "destination", "upsert", "telemetry-dst-upsert-" + generateTimestamp(), "--type", "HTTP", "--url", "https://example.com"}, "hookdeck gateway destination upsert") } func TestTelemetryGatewayDestinationCountProxy(t *testing.T) { @@ -968,7 +968,7 @@ func TestTelemetryGatewayTransformationUpsertProxy(t *testing.T) { t.Skip("Skipping acceptance test in short mode") } runTelemetryProxyTestSuccess(t, - []string{"gateway", "transformation", "upsert", "telemetry-trn-upsert-"+generateTimestamp(), "--code", `addHandler("transform", (request, context) => { return request; });`}, + []string{"gateway", "transformation", "upsert", "telemetry-trn-upsert-" + generateTimestamp(), "--code", `addHandler("transform", (request, context) => { return request; });`}, "hookdeck gateway transformation upsert") } func TestTelemetryGatewayTransformationCountProxy(t *testing.T) { @@ -1313,8 +1313,14 @@ func TestTelemetryGatewayIssueGetProxy(t *testing.T) { issueID := listResp.Models[0].ID proxy := StartRecordingProxy(t, defaultAPIUpstream) defer proxy.Close() - _, _, err := cli.Run("--api-base", proxy.URL(), "gateway", "issue", "get", issueID) - require.NoError(t, err) + stdout, stderr, err := cli.Run("--api-base", proxy.URL(), "gateway", "issue", "get", issueID) + // Carry the command output into the failure. This assertion used to report + // only "exit status 1", which said the CLI failed and nothing about why - + // so an intermittent failure here could not be diagnosed from a CI log and + // was only ever re-run. The issue id is included because this test, unlike + // its siblings, does not filter to OPENED and so may pick up an issue in + // any state. + require.NoError(t, err, "gateway issue get %s failed\nstdout: %s\nstderr: %s", issueID, stdout, stderr) recorded := proxy.Recorded() require.GreaterOrEqual(t, len(recorded), 1) AssertTelemetryConsistent(t, recorded, "hookdeck gateway issue get")