diff --git a/pkg/listen/forwardurl/forwardurl.go b/pkg/listen/forwardurl/forwardurl.go new file mode 100644 index 00000000..a95e93ab --- /dev/null +++ b/pkg/listen/forwardurl/forwardurl.go @@ -0,0 +1,27 @@ +// Package forwardurl builds the URL that `hookdeck listen` forwards an event +// to. The request and every place that prints the URL share it, so what the +// CLI shows is what the CLI sent. +package forwardurl + +import ( + "net/url" + "strings" +) + +// JoinPath appends the event's path to the forwarding URL's path with a single +// "/" between them. A trailing "/" on the forwarding URL is dropped only when +// the event path starts with one, and the event path itself is left as is: a +// receiver may route on its own trailing slash, which is why path.Join is not +// used. +func JoinPath(base, eventPath string) string { + if strings.HasPrefix(eventPath, "/") { + base = strings.TrimSuffix(base, "/") + } + return base + eventPath +} + +// Build returns the full URL for an event: the forwarding URL's scheme, host +// and path, then the event's path. +func Build(target *url.URL, eventPath string) string { + return target.Scheme + "://" + target.Host + JoinPath(target.Path, eventPath) +} diff --git a/pkg/listen/forwardurl/forwardurl_test.go b/pkg/listen/forwardurl/forwardurl_test.go new file mode 100644 index 00000000..9a58281e --- /dev/null +++ b/pkg/listen/forwardurl/forwardurl_test.go @@ -0,0 +1,33 @@ +package forwardurl + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuild(t *testing.T) { + tests := []struct { + name string + target string + eventPath string + want string + }{ + {"forwarding URL with a trailing slash", "http://localhost:3000/base/", "/webhook", "http://localhost:3000/base/webhook"}, + {"forwarding URL without a trailing slash", "http://localhost:3000/base", "/webhook", "http://localhost:3000/base/webhook"}, + {"forwarding URL is the root", "http://localhost:3000/", "/webhook", "http://localhost:3000/webhook"}, + {"forwarding URL has no path", "http://localhost:3000", "/webhook", "http://localhost:3000/webhook"}, + {"event path keeps its own trailing slash", "http://localhost:3000/base/", "/webhook/", "http://localhost:3000/base/webhook/"}, + {"event path is empty", "http://localhost:3000/base/", "", "http://localhost:3000/base/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, err := url.Parse(tt.target) + require.NoError(t, err) + assert.Equal(t, tt.want, Build(target, tt.eventPath)) + }) + } +} diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 8bd9fbfa..565e6b2c 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -10,6 +10,7 @@ import ( "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/forwardurl" "github.com/hookdeck/hookdeck-cli/pkg/listen/links" "github.com/hookdeck/hookdeck-cli/pkg/listen/summary" ) @@ -61,7 +62,7 @@ func printSourcesWithConnections(config *config.Config, projectID string, source if cliPath != nil { path = *cliPath } - fullPath := targetURL.Scheme + "://" + targetURL.Host + path + fullPath := forwardurl.Build(targetURL, path) // Get connection name from FullName (format: "source -> destination") // Split on "->" and take the second part (destination) diff --git a/pkg/listen/printer_forward_path_test.go b/pkg/listen/printer_forward_path_test.go new file mode 100644 index 00000000..6c8bc003 --- /dev/null +++ b/pkg/listen/printer_forward_path_test.go @@ -0,0 +1,44 @@ +package listen + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// The startup banner lists "Forwards to → " for each connection. It has to +// show the URL the events will be forwarded to, including the forwarding URL's +// path, joined to the connection's path with a single "/". +func TestCompactBannerForwardsToJoinsForwardingURLAndConnectionPath(t *testing.T) { + for _, tc := range []struct { + name string + forwardTo string + cliPath string + want string + }{ + {"trailing slash on the forwarding URL", "http://localhost:3030/base/", "/webhook", "Forwards to → http://localhost:3030/base/webhook"}, + {"no trailing slash on the forwarding URL", "http://localhost:3030/base", "/webhook", "Forwards to → http://localhost:3030/base/webhook"}, + {"forwarding URL has no path", "http://localhost:3030", "/webhook", "Forwards to → http://localhost:3030/webhook"}, + {"connection path is the default root", "http://localhost:3030/base/", "/", "Forwards to → http://localhost:3030/base/"}, + } { + t.Run(tc.name, func(t *testing.T) { + resetColorState(t) + cfg, sources, connections, _ := printerFixture(t, 1) + targetURL, err := url.Parse(tc.forwardTo) + require.NoError(t, err) + connections[0].Destination = &hookdeck.Destination{ + ID: "des_1", Type: "CLI", Config: map[string]interface{}{"path": tc.cliPath}, + } + + out := capturePrinterOutput(t, func() { + printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "") + }) + + assert.Contains(t, out, tc.want) + }) + } +} diff --git a/pkg/listen/proxy/forward_path_test.go b/pkg/listen/proxy/forward_path_test.go new file mode 100644 index 00000000..3cac012e --- /dev/null +++ b/pkg/listen/proxy/forward_path_test.go @@ -0,0 +1,62 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +func TestProcessAttemptJoinsForwardingURLPathAndEventPath(t *testing.T) { + tests := []struct { + name string + forwardTo string + eventPath string + expectPath string + }{ + {"forwarding URL with a trailing slash", "/base/", "/webhook", "/base/webhook"}, + {"forwarding URL without a trailing slash", "/base", "/webhook", "/base/webhook"}, + {"forwarding URL is the root", "/", "/webhook", "/webhook"}, + {"forwarding URL has no path", "", "/webhook", "/webhook"}, + {"event path keeps its own trailing slash", "/base/", "/webhook/", "/base/webhook/"}, + {"event path is empty", "/base/", "", "/base/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + received := make(chan string, 1) + local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- r.URL.Path + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(local.Close) + + target, err := url.Parse(local.URL + tt.forwardTo) + require.NoError(t, err) + p := New(&Config{URL: target, NoHealthcheck: true}, nil, newRecordingRenderer()) + + p.processAttempt(websocket.IncomingMessage{Attempt: &websocket.Attempt{ + Body: websocket.AttemptBody{ + Path: tt.eventPath, + EventID: "evt_test", + AttemptId: "evt_test", + Request: websocket.AttemptRequest{Method: http.MethodPost, DataString: "{}", Headers: json.RawMessage(`{}`)}, + }, + }}) + + select { + case got := <-received: + assert.Equal(t, tt.expectPath, got) + case <-time.After(5 * time.Second): + t.Fatal("local server never received the request") + } + }) + } +} diff --git a/pkg/listen/proxy/proxy.go b/pkg/listen/proxy/proxy.go index 81e597c4..b37ad0c3 100644 --- a/pkg/listen/proxy/proxy.go +++ b/pkg/listen/proxy/proxy.go @@ -22,6 +22,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl" "github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -400,7 +401,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { "prefix": "proxy.Proxy.processAttempt", }).Debugf("Processing webhook event") - url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path + url := forwardurl.Build(p.cfg.URL, webhookEvent.Body.Path) // Create request with context for timeout control timeout := webhookEvent.Body.Request.Timeout diff --git a/pkg/listen/proxy/renderer_forward_path_test.go b/pkg/listen/proxy/renderer_forward_path_test.go new file mode 100644 index 00000000..a8a8576f --- /dev/null +++ b/pkg/listen/proxy/renderer_forward_path_test.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/listen/tui" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +// The request is sent to a URL built in processAttempt, and each renderer +// prints the URL it believes was requested. These tests pin both renderers to +// the single-slash join, so a forwarding URL ending in "/" does not print +// "/base//webhook" for a request that went to "/base/webhook". + +func forwardPathRendererConfig(t *testing.T, forwardTo string) *RendererConfig { + t.Helper() + cfg := linksRendererConfig(t, "gateway") + target, err := url.Parse(forwardTo) + require.NoError(t, err) + cfg.TargetURL = target + return cfg +} + +func forwardPathAttempt(eventPath string) *websocket.Attempt { + return &websocket.Attempt{Body: websocket.AttemptBody{ + Path: eventPath, + EventID: "evt_1", + AttemptId: "atm_1", + ConnectionId: "web_1", + Request: websocket.AttemptRequest{Method: "POST"}, + }} +} + +var forwardPathRendererCases = []struct { + name string + forwardTo string + eventPath string + want string +}{ + {"trailing slash on the forwarding URL", "http://localhost:3000/base/", "/webhook", "POST http://localhost:3000/base/webhook "}, + {"no trailing slash on the forwarding URL", "http://localhost:3000/base", "/webhook", "POST http://localhost:3000/base/webhook "}, + {"forwarding URL is the root", "http://localhost:3000/", "/webhook", "POST http://localhost:3000/webhook "}, + {"event path keeps its own trailing slash", "http://localhost:3000/base/", "/webhook/", "POST http://localhost:3000/base/webhook/ "}, +} + +func TestSimpleRendererJoinsForwardingURLAndEventPath(t *testing.T) { + response := &EventResponse{StatusCode: 200, Duration: 5 * time.Millisecond} + + for _, tc := range forwardPathRendererCases { + t.Run(tc.name, func(t *testing.T) { + r := NewSimpleRenderer(forwardPathRendererConfig(t, tc.forwardTo), false) + out := captureStdout(t, func() { + r.OnEventComplete("evt_1", forwardPathAttempt(tc.eventPath), response, time.Now()) + }) + assert.Contains(t, out, tc.want) + }) + } +} + +func TestInteractiveRendererJoinsForwardingURLAndEventPath(t *testing.T) { + response := &EventResponse{StatusCode: 200, Duration: 5 * time.Millisecond} + + for _, tc := range forwardPathRendererCases { + t.Run(tc.name, func(t *testing.T) { + r, sent := recordingInteractiveRenderer() + r.cfg = forwardPathRendererConfig(t, tc.forwardTo) + + r.OnEventComplete("evt_1", forwardPathAttempt(tc.eventPath), response, time.Now()) + + require.Len(t, *sent, 1) + update, ok := (*sent)[0].(tui.UpdateEventMsg) + require.True(t, ok, "unexpected message %T", (*sent)[0]) + assert.Contains(t, update.LogLine, tc.want) + }) + } +} diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index aeb68fcd..91485bec 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/listen/forwardurl" "github.com/hookdeck/hookdeck-cli/pkg/listen/tui" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -152,7 +153,7 @@ func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket color.Faint(localTime), ansi.ColorizeStatus(response.StatusCode), attempt.Body.Request.Method, - r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path, + forwardurl.Build(r.cfg.TargetURL, attempt.Body.Path), color.Faint(fmt.Sprintf("(%dms)", durationMs)), color.Faint("→"), color.Faint(displayURL), diff --git a/pkg/listen/proxy/renderer_simple.go b/pkg/listen/proxy/renderer_simple.go index db6bb52a..ee2c4aea 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/listen/forwardurl" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -149,7 +150,7 @@ func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Atte color.Faint(localTime), ansi.ColorizeStatus(response.StatusCode), attempt.Body.Request.Method, - r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path, + forwardurl.Build(r.cfg.TargetURL, attempt.Body.Path), color.Faint(fmt.Sprintf("(%dms)", durationMs)), color.Faint("→"), color.Faint(displayURL), diff --git a/pkg/listen/tui/forward_path_test.go b/pkg/listen/tui/forward_path_test.go new file mode 100644 index 00000000..8f1bb0a4 --- /dev/null +++ b/pkg/listen/tui/forward_path_test.go @@ -0,0 +1,77 @@ +package tui + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +// The TUI prints the forwarded URL in two places, and both used to leave the +// forwarding URL's path out or join it with a doubled "/". + +// The request line in the event details (and the copied request) is the URL the +// event was forwarded to. +func TestEventDetailsRequestLineJoinsForwardingURLAndEventPath(t *testing.T) { + for _, tc := range []struct { + name string + forwardTo string + eventPath string + want string + }{ + {"trailing slash on the forwarding URL", "http://localhost:3500/base/", "/webhook", "POST http://localhost:3500/base/webhook"}, + {"no trailing slash on the forwarding URL", "http://localhost:3500/base", "/webhook", "POST http://localhost:3500/base/webhook"}, + {"forwarding URL is the root", "http://localhost:3500/", "/webhook", "POST http://localhost:3500/webhook"}, + {"event path keeps its own trailing slash", "http://localhost:3500/base/", "/webhook/", "POST http://localhost:3500/base/webhook/"}, + } { + t.Run(tc.name, func(t *testing.T) { + m := newDetailsTestModel(t) + target, err := url.Parse(tc.forwardTo) + require.NoError(t, err) + m.cfg.TargetURL = target + + event := &EventInfo{ + ID: "evt_test", + Data: &websocket.Attempt{Body: websocket.AttemptBody{ + Path: tc.eventPath, + Request: websocket.AttemptRequest{Method: "POST", DataString: "{}"}, + }}, + } + + content, copyContent := m.buildEventDetailsContent(event) + + assert.Equal(t, tc.want, copyContent.requestLine) + assert.Contains(t, content, tc.want) + }) + } +} + +// The header lists each connection under its source as "Forwards to → ". +func TestConnectionInfoForwardsToJoinsForwardingURLAndConnectionPath(t *testing.T) { + for _, tc := range []struct { + name string + forwardTo string + cliPath string + want string + }{ + {"trailing slash on the forwarding URL", "http://localhost:3030/base/", "/webhook", "Forwards to → http://localhost:3030/base/webhook"}, + {"no trailing slash on the forwarding URL", "http://localhost:3030/base", "/webhook", "Forwards to → http://localhost:3030/base/webhook"}, + {"forwarding URL has no path", "http://localhost:3030", "/webhook", "Forwards to → http://localhost:3030/webhook"}, + } { + t.Run(tc.name, func(t *testing.T) { + m := newConnectionTestModel(t) + target, err := url.Parse(tc.forwardTo) + require.NoError(t, err) + m.cfg.TargetURL = target + m.cfg.Connections[0].Destination = &hookdeck.Destination{ + ID: "des_1", Type: "CLI", Config: map[string]interface{}{"path": tc.cliPath}, + } + + assert.Contains(t, m.renderConnectionInfo(), tc.want) + }) + } +} diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index a253b25b..d022c5b8 100644 --- a/pkg/listen/tui/model.go +++ b/pkg/listen/tui/model.go @@ -13,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl" "github.com/hookdeck/hookdeck-cli/pkg/login" "github.com/hookdeck/hookdeck-cli/pkg/websocket" ) @@ -347,7 +348,7 @@ func (m *Model) buildEventDetailsContent(event *EventInfo) (string, requestCopyC content.WriteString("\n\n") // HTTP request line: METHOD URL - requestURL := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + event.Data.Body.Path + requestURL := forwardurl.Build(m.cfg.TargetURL, event.Data.Body.Path) requestCopy.requestLine = event.Data.Body.Request.Method + " " + requestURL content.WriteString(requestCopy.requestLine + "\n\n") diff --git a/pkg/listen/tui/view.go b/pkg/listen/tui/view.go index 61a705b5..c528c814 100644 --- a/pkg/listen/tui/view.go +++ b/pkg/listen/tui/view.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl" "github.com/hookdeck/hookdeck-cli/pkg/listen/summary" ) @@ -410,7 +411,7 @@ func (m Model) renderConnectionInfo() string { if conns, exists := sourceConnections[source.ID]; exists { numConns := len(conns) for j, conn := range conns { - fullPath := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + conn.cliPath + fullPath := forwardurl.Build(m.cfg.TargetURL, conn.cliPath) connDisplay := "" if conn.destName != "" {