Skip to content

Commit 0066e7b

Browse files
leggetterclaude
andauthored
Fix connection upsert bugs with rules, auth, and partial updates (#221)
* fix: connection upsert fails when updating rules on connection with destination auth Fixes three bugs reported in #209: 1. Auth type sent without credentials: The `destination-cli-path` flag defaulted to "/" in the upsert command, making `hasAnyDestinationFlag()` always return true. This caused `buildDestinationInputForUpdate` to copy the entire existing destination config (including auth_type) even when no destination flags were provided, sending auth_type without credentials. Fix: Change the default to "" for upsert and add a destination ID preservation fallback. 2. Source name validation issue: `validateSourceFlags()` required both --source-name and --source-type even during upsert updates. For updates, providing just --source-name should be valid since the existing type can be preserved. Fix: Relax validation for upsert and fill in missing type from existing connection. 3. Status code format problem: `buildConnectionRules` sent `response_status_codes` as a single comma-separated string instead of parsing it into a string array, causing API rejection. Fix: Split the comma-separated string into a []string array. Note: Bug 3 fix affects all commands using buildConnectionRules (create, update, upsert) since the function is shared. https://claude.ai/code/session_01D8k5gyzeqzjZUanVrwv5dL * refactor: merge bug tests into existing test files with descriptive names Move acceptance tests from separate connection_upsert_bugs_test.go into the existing connection_upsert_test.go (as subtests of TestConnectionUpsertPartialUpdates) and connection_update_test.go. Rename unit tests from connection_upsert_bugs_test.go to connection_upsert_test.go with descriptive test function names. https://claude.ai/code/session_01D8k5gyzeqzjZUanVrwv5dL * ci: add unit tests to CI and fix broken TestSourceCreateRequiresName - Add unit-test job to test.yml that runs `go test -short ./pkg/...` on every PR. Previously only GoReleaser builds ran, so unit test failures were never caught. - Fix TestSourceCreateRequiresName: the old test executed the real root command which hit ValidateAPIKey() before cobra's required-flag check, causing a misleading "not authenticated" error. Replaced with a direct check that --name and --type have the required-flag annotation. - Add TestConnectionCreateRetryResponseStatusCodes acceptance test for Bug 3 coverage on the create command path (update and upsert were already covered). https://claude.ai/code/session_01D8k5gyzeqzjZUanVrwv5dL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b33ddc commit 0066e7b

8 files changed

Lines changed: 605 additions & 21 deletions

File tree

.github/workflows/test.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ on:
1010
- main
1111

1212
jobs:
13+
unit-test:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Code checkout
17+
uses: actions/checkout@v4
18+
- name: Set up Go
19+
uses: actions/setup-go@v5
20+
with:
21+
go-version: 1.24.9
22+
- name: Run unit tests
23+
run: go test -short ./pkg/...
24+
1325
build-mac:
1426
runs-on: macos-latest
1527
steps:

pkg/cmd/connection_common.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,11 @@ func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) {
191191
rule["interval"] = f.RuleRetryInterval
192192
}
193193
if f.RuleRetryResponseStatusCode != "" {
194-
rule["response_status_codes"] = f.RuleRetryResponseStatusCode
194+
codes := strings.Split(f.RuleRetryResponseStatusCode, ",")
195+
for i := range codes {
196+
codes[i] = strings.TrimSpace(codes[i])
197+
}
198+
rule["response_status_codes"] = codes
195199
}
196200
rules = append(rules, rule)
197201
}

pkg/cmd/connection_upsert.go

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func newConnectionUpsertCmd() *connectionUpsertCmd {
9494
cu.cmd.Flags().StringVar(&cu.destinationType, "destination-type", "", "Destination type (CLI, HTTP, MOCK)")
9595
cu.cmd.Flags().StringVar(&cu.destinationDescription, "destination-description", "", "Destination description")
9696
cu.cmd.Flags().StringVar(&cu.destinationURL, "destination-url", "", "URL for HTTP destinations")
97-
cu.cmd.Flags().StringVar(&cu.destinationCliPath, "destination-cli-path", "/", "CLI path for CLI destinations (default: /)")
97+
cu.cmd.Flags().StringVar(&cu.destinationCliPath, "destination-cli-path", "", "CLI path for CLI destinations (default: / for new connections)")
9898

9999
// Use a string flag to allow explicit true/false values
100100
var pathForwardingDisabledStr string
@@ -257,10 +257,10 @@ func (cu *connectionUpsertCmd) validateSourceFlags() error {
257257
return fmt.Errorf("cannot use --source-id with --source-name or --source-type")
258258
}
259259

260-
// If creating inline, require both name and type
261-
if (cu.sourceName != "" || cu.sourceType != "") && (cu.sourceName == "" || cu.sourceType == "") {
262-
return fmt.Errorf("both --source-name and --source-type are required for inline source creation")
263-
}
260+
// For upsert, we don't require both --source-name and --source-type.
261+
// If the connection already exists, providing just --source-name is valid
262+
// (the existing source type will be preserved). The API will reject
263+
// incomplete data if this is actually a create.
264264

265265
return nil
266266
}
@@ -272,10 +272,10 @@ func (cu *connectionUpsertCmd) validateDestinationFlags() error {
272272
return fmt.Errorf("cannot use --destination-id with --destination-name or --destination-type")
273273
}
274274

275-
// If creating inline, require both name and type
276-
if (cu.destinationName != "" || cu.destinationType != "") && (cu.destinationName == "" || cu.destinationType == "") {
277-
return fmt.Errorf("both --destination-name and --destination-type are required for inline destination creation")
278-
}
275+
// For upsert, we don't require both --destination-name and --destination-type.
276+
// If the connection already exists, providing just --destination-name is valid
277+
// (the existing destination type will be preserved). The API will reject
278+
// incomplete data if this is actually a create.
279279

280280
return nil
281281
}
@@ -304,7 +304,11 @@ func (cu *connectionUpsertCmd) runConnectionUpsertCmd(cmd *cobra.Command, args [
304304
cu.DestinationRateLimit != 0 || cu.DestinationAuthMethod != "") &&
305305
cu.destinationName == "" && cu.destinationType == "" && cu.destinationID == ""
306306

307-
needsExisting := cu.dryRun || (!cu.hasAnySourceFlag() && !cu.hasAnyDestinationFlag()) || hasSourceConfigOnly || hasDestinationConfigOnly
307+
// Also need to fetch existing when name is provided without type (to fill in the type)
308+
hasPartialSourceInline := (cu.sourceName != "" && cu.sourceType == "" && cu.sourceID == "")
309+
hasPartialDestinationInline := (cu.destinationName != "" && cu.destinationType == "" && cu.destinationID == "")
310+
311+
needsExisting := cu.dryRun || (!cu.hasAnySourceFlag() && !cu.hasAnyDestinationFlag()) || hasSourceConfigOnly || hasDestinationConfigOnly || hasPartialSourceInline || hasPartialDestinationInline
308312

309313
var existing *hookdeck.Connection
310314
var isUpdate bool
@@ -411,6 +415,10 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection,
411415
if cu.sourceID != "" {
412416
req.SourceID = &cu.sourceID
413417
} else if cu.sourceName != "" || cu.sourceType != "" {
418+
// For upsert updates, fill in missing source type from existing connection
419+
if cu.sourceType == "" && isUpdate && existing != nil && existing.Source != nil {
420+
cu.sourceType = existing.Source.Type
421+
}
414422
sourceInput, err := cu.buildSourceInput()
415423
if err != nil {
416424
return nil, err
@@ -442,6 +450,14 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection,
442450
if cu.destinationID != "" {
443451
req.DestinationID = &cu.destinationID
444452
} else if cu.destinationName != "" || cu.destinationType != "" {
453+
// For upsert updates, fill in missing destination type from existing connection
454+
if cu.destinationType == "" && isUpdate && existing != nil && existing.Destination != nil {
455+
cu.destinationType = existing.Destination.Type
456+
}
457+
// Default CLI path to "/" for new CLI destinations when not explicitly set
458+
if strings.ToUpper(cu.destinationType) == "CLI" && cu.destinationCliPath == "" {
459+
cu.destinationCliPath = "/"
460+
}
445461
destinationInput, err := cu.buildDestinationInput()
446462
if err != nil {
447463
return nil, err
@@ -469,10 +485,13 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection,
469485
}
470486
}
471487

472-
// Also preserve source if not specified
488+
// Preserve existing source/destination if not specified
473489
if req.SourceID == nil && req.Source == nil && isUpdate && existing != nil && existing.Source != nil {
474490
req.SourceID = &existing.Source.ID
475491
}
492+
if req.DestinationID == nil && req.Destination == nil && isUpdate && existing != nil && existing.Destination != nil {
493+
req.DestinationID = &existing.Destination.ID
494+
}
476495

477496
// Handle Rules
478497
rules, err := buildConnectionRules(&cu.connectionCreateCmd.connectionRuleFlags)

pkg/cmd/connection_upsert_test.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func strPtr(s string) *string {
13+
return &s
14+
}
15+
16+
// TestBuildConnectionRulesRetryStatusCodesArray verifies that buildConnectionRules
17+
// produces response_status_codes as a []string array, not a single string.
18+
// Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 3.
19+
func TestBuildConnectionRulesRetryStatusCodesArray(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
flags connectionRuleFlags
23+
wantCodes []string
24+
wantCodeCount int
25+
wantRuleCount int
26+
}{
27+
{
28+
name: "comma-separated status codes should produce array",
29+
flags: connectionRuleFlags{
30+
RuleRetryStrategy: "linear",
31+
RuleRetryCount: 3,
32+
RuleRetryInterval: 5000,
33+
RuleRetryResponseStatusCode: "500,502,503,504",
34+
},
35+
wantCodes: []string{"500", "502", "503", "504"},
36+
wantCodeCount: 4,
37+
wantRuleCount: 1,
38+
},
39+
{
40+
name: "single status code should produce single-element array",
41+
flags: connectionRuleFlags{
42+
RuleRetryStrategy: "exponential",
43+
RuleRetryResponseStatusCode: "500",
44+
},
45+
wantCodes: []string{"500"},
46+
wantCodeCount: 1,
47+
wantRuleCount: 1,
48+
},
49+
{
50+
name: "status codes with spaces should be trimmed",
51+
flags: connectionRuleFlags{
52+
RuleRetryStrategy: "linear",
53+
RuleRetryResponseStatusCode: "500, 502, 503",
54+
},
55+
wantCodes: []string{"500", "502", "503"},
56+
wantCodeCount: 3,
57+
wantRuleCount: 1,
58+
},
59+
{
60+
name: "no status codes should not include response_status_codes",
61+
flags: connectionRuleFlags{
62+
RuleRetryStrategy: "linear",
63+
RuleRetryCount: 3,
64+
},
65+
wantCodes: nil,
66+
wantCodeCount: 0,
67+
wantRuleCount: 1,
68+
},
69+
}
70+
71+
for _, tt := range tests {
72+
t.Run(tt.name, func(t *testing.T) {
73+
rules, err := buildConnectionRules(&tt.flags)
74+
require.NoError(t, err, "buildConnectionRules should not error")
75+
require.Len(t, rules, tt.wantRuleCount, "Expected %d rule(s)", tt.wantRuleCount)
76+
77+
if tt.wantRuleCount == 0 {
78+
return
79+
}
80+
81+
retryRule := rules[len(rules)-1]
82+
assert.Equal(t, "retry", retryRule["type"], "Last rule should be retry")
83+
84+
if tt.wantCodes == nil {
85+
_, exists := retryRule["response_status_codes"]
86+
assert.False(t, exists, "response_status_codes should not be present when not specified")
87+
return
88+
}
89+
90+
statusCodes, ok := retryRule["response_status_codes"]
91+
require.True(t, ok, "response_status_codes should be present")
92+
93+
codesSlice, ok := statusCodes.([]string)
94+
require.True(t, ok, "response_status_codes should be []string, got %T", statusCodes)
95+
assert.Equal(t, tt.wantCodeCount, len(codesSlice))
96+
assert.Equal(t, tt.wantCodes, codesSlice)
97+
98+
// Verify it serializes to a JSON array
99+
jsonBytes, err := json.Marshal(retryRule)
100+
require.NoError(t, err)
101+
102+
var parsed map[string]interface{}
103+
err = json.Unmarshal(jsonBytes, &parsed)
104+
require.NoError(t, err)
105+
106+
jsonCodes, ok := parsed["response_status_codes"].([]interface{})
107+
require.True(t, ok, "JSON response_status_codes should be an array, got %T", parsed["response_status_codes"])
108+
assert.Len(t, jsonCodes, tt.wantCodeCount)
109+
})
110+
}
111+
}
112+
113+
// TestUpsertBuildRequestRulesOnlyPreservesDestinationByID verifies that when
114+
// upserting with only rule flags, the request uses destination_id (not a full
115+
// destination object that could include incomplete auth config).
116+
// Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 1.
117+
func TestUpsertBuildRequestRulesOnlyPreservesDestinationByID(t *testing.T) {
118+
cu := &connectionUpsertCmd{
119+
connectionCreateCmd: &connectionCreateCmd{},
120+
}
121+
cu.name = "test-conn"
122+
// Only set rule flags, no source/destination flags
123+
cu.RuleRetryStrategy = "linear"
124+
cu.RuleRetryCount = 3
125+
126+
existing := &hookdeck.Connection{
127+
ID: "conn_123",
128+
Name: strPtr("test-conn"),
129+
Source: &hookdeck.Source{
130+
ID: "src_123",
131+
Name: "test-source",
132+
Type: "WEBHOOK",
133+
},
134+
Destination: &hookdeck.Destination{
135+
ID: "dst_123",
136+
Name: "test-dest",
137+
Type: "HTTP",
138+
Config: map[string]interface{}{
139+
"url": "https://api.example.com",
140+
"auth_type": "AWS_SIGNATURE",
141+
},
142+
},
143+
}
144+
145+
req, err := cu.buildUpsertRequest(existing, true)
146+
require.NoError(t, err)
147+
148+
// Should reference existing destination by ID, not recreate it
149+
assert.NotNil(t, req.DestinationID, "Should use DestinationID")
150+
assert.Equal(t, "dst_123", *req.DestinationID)
151+
assert.Nil(t, req.Destination, "Should NOT send full Destination object")
152+
153+
// Source should also be preserved by ID
154+
assert.NotNil(t, req.SourceID, "Should use SourceID")
155+
assert.Equal(t, "src_123", *req.SourceID)
156+
assert.Nil(t, req.Source, "Should NOT send full Source object")
157+
158+
// Rules should be present
159+
assert.NotEmpty(t, req.Rules)
160+
}
161+
162+
// TestUpsertHasAnyDestinationFlagIgnoresDefault verifies that hasAnyDestinationFlag
163+
// returns false when no destination flags are explicitly set (the cli-path default
164+
// of "/" was previously causing this to always return true).
165+
// Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 1.
166+
func TestUpsertHasAnyDestinationFlagIgnoresDefault(t *testing.T) {
167+
cu := &connectionUpsertCmd{
168+
connectionCreateCmd: &connectionCreateCmd{},
169+
}
170+
// No destination flags set at all (cli-path default is "" for upsert)
171+
172+
result := cu.hasAnyDestinationFlag()
173+
assert.False(t, result, "hasAnyDestinationFlag should be false with no destination flags set")
174+
}
175+
176+
// TestUpsertValidateSourceFlagsAllowsNameOnly verifies that validateSourceFlags
177+
// allows --source-name without --source-type for the upsert command.
178+
// Regression test for https://github.com/hookdeck/hookdeck-cli/issues/209 Bug 2.
179+
func TestUpsertValidateSourceFlagsAllowsNameOnly(t *testing.T) {
180+
cu := &connectionUpsertCmd{
181+
connectionCreateCmd: &connectionCreateCmd{},
182+
}
183+
cu.sourceName = "my-source"
184+
// sourceType intentionally empty
185+
186+
err := cu.validateSourceFlags()
187+
assert.NoError(t, err, "validateSourceFlags should allow --source-name alone for upsert")
188+
}
189+
190+
// TestUpsertValidateDestinationFlagsAllowsNameOnly verifies the same relaxation
191+
// for destination flags.
192+
func TestUpsertValidateDestinationFlagsAllowsNameOnly(t *testing.T) {
193+
cu := &connectionUpsertCmd{
194+
connectionCreateCmd: &connectionCreateCmd{},
195+
}
196+
cu.destinationName = "my-dest"
197+
// destinationType intentionally empty
198+
199+
err := cu.validateDestinationFlags()
200+
assert.NoError(t, err, "validateDestinationFlags should allow --destination-name alone for upsert")
201+
}
202+
203+
// TestUpsertBuildRequestFillsSourceTypeFromExisting verifies that when
204+
// --source-name is provided without --source-type during an update,
205+
// the existing source type is used.
206+
func TestUpsertBuildRequestFillsSourceTypeFromExisting(t *testing.T) {
207+
cu := &connectionUpsertCmd{
208+
connectionCreateCmd: &connectionCreateCmd{},
209+
}
210+
cu.name = "test-conn"
211+
cu.sourceName = "new-source-name"
212+
// sourceType intentionally empty - should be filled from existing
213+
214+
existing := &hookdeck.Connection{
215+
ID: "conn_123",
216+
Name: strPtr("test-conn"),
217+
Source: &hookdeck.Source{
218+
ID: "src_123",
219+
Name: "old-source-name",
220+
Type: "WEBHOOK",
221+
},
222+
Destination: &hookdeck.Destination{
223+
ID: "dst_123",
224+
Name: "test-dest",
225+
Type: "HTTP",
226+
Config: map[string]interface{}{
227+
"url": "https://api.example.com",
228+
},
229+
},
230+
}
231+
232+
req, err := cu.buildUpsertRequest(existing, true)
233+
require.NoError(t, err)
234+
235+
// Source should be an inline input (not just ID) since name was changed
236+
require.NotNil(t, req.Source, "Should have Source input for name change")
237+
assert.Equal(t, "new-source-name", req.Source.Name)
238+
assert.Equal(t, "WEBHOOK", req.Source.Type, "Should fill type from existing source")
239+
}

0 commit comments

Comments
 (0)