diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee12f617..71716a981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ### Bug fixes: - fix(compute): clarify fastly.toml error message when file not found ([#1556](https://github.com/fastly/cli/pull/1556)) +- fix(purge/key): ensures that single-key purges will work even if the key contains URL-unsafe characters ([#1566](https://github.com/fastly/cli/pull/1566)) ### Dependencies: - build(deps): `github.com/hashicorp/cap` from 0.10.0 to 0.11.0 ([#1546](https://github.com/fastly/cli/pull/1546)) diff --git a/pkg/commands/purge/purge_test.go b/pkg/commands/purge/purge_test.go index 73c2e1677..0c3d17791 100644 --- a/pkg/commands/purge/purge_test.go +++ b/pkg/commands/purge/purge_test.go @@ -13,6 +13,11 @@ import ( ) func TestPurgeAll(t *testing.T) { + const ( + testServiceID = "123" + testStatus = "ok" + ) + scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", @@ -21,7 +26,7 @@ func TestPurgeAll(t *testing.T) { }, { Name: "validate --soft flag isn't usable", - Args: "--all --service-id 123 --soft", + Args: "--all --service-id " + testServiceID + " --soft", WantError: "purge-all requests cannot be done in soft mode (--soft) and will always immediately invalidate all cached content associated with the service", }, { @@ -31,7 +36,7 @@ func TestPurgeAll(t *testing.T) { return nil, testutil.Err }, }, - Args: "--all --service-id 123", + Args: "--all --service-id " + testServiceID, WantError: testutil.Err.Error(), }, { @@ -39,12 +44,12 @@ func TestPurgeAll(t *testing.T) { API: mock.API{ PurgeAllFn: func(_ context.Context, _ *fastly.PurgeAllInput) (*fastly.Purge, error) { return &fastly.Purge{ - Status: fastly.ToPointer("ok"), + Status: fastly.ToPointer(testStatus), }, nil }, }, - Args: "--all --service-id 123", - WantOutput: "Purge all status: ok", + Args: "--all --service-id " + testServiceID, + WantOutput: "Purge all status: " + testStatus, }, } @@ -52,6 +57,16 @@ func TestPurgeAll(t *testing.T) { } func TestPurgeKeys(t *testing.T) { + const ( + testServiceID = "123" + testKey1 = "foo" + testKey2 = "bar" + testKey3 = "baz" + testPurgeID1 = "123" + testPurgeID2 = "456" + testPurgeID3 = "789" + ) + var keys []string scenarios := []testutil.CLIScenario{ { @@ -66,7 +81,7 @@ func TestPurgeKeys(t *testing.T) { return nil, testutil.Err }, }, - Args: "--file ./testdata/keys --service-id 123", + Args: "--file ./testdata/keys --service-id " + testServiceID, WantError: testutil.Err.Error(), }, { @@ -77,13 +92,13 @@ func TestPurgeKeys(t *testing.T) { keys = i.Keys return map[string]string{ - "foo": "123", - "bar": "456", - "baz": "789", + testKey1: testPurgeID1, + testKey2: testPurgeID2, + testKey3: testPurgeID3, }, nil }, }, - Args: "--file ./testdata/keys --service-id 123", + Args: "--file ./testdata/keys --service-id " + testServiceID, WantOutput: "KEY ID\nbar 456\nbaz 789\nfoo 123\n", }, } @@ -103,47 +118,52 @@ func assertKeys(keys []string, t *testing.T) { } func TestPurgeKey(t *testing.T) { + const ( + testServiceID = "123" + testKey = "foobar" + testPurgeID = "123" + testStatus = "ok" + ) + scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", - Args: "--key foobar", + Args: "--key " + testKey, WantError: "error reading service: no service ID found", }, { - Name: "validate PurgeKey API error", + Name: "validate PurgeKeys API error", API: mock.API{ - PurgeKeyFn: func(_ context.Context, _ *fastly.PurgeKeyInput) (*fastly.Purge, error) { + PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { return nil, testutil.Err }, }, - Args: "--key foobar --service-id 123", + Args: "--key " + testKey + " --service-id " + testServiceID, WantError: testutil.Err.Error(), }, { - Name: "validate PurgeKey API success", + Name: "validate PurgeKeys API success", API: mock.API{ - PurgeKeyFn: func(_ context.Context, _ *fastly.PurgeKeyInput) (*fastly.Purge, error) { - return &fastly.Purge{ - Status: fastly.ToPointer("ok"), - PurgeID: fastly.ToPointer("123"), + PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { + return map[string]string{ + testKey: testPurgeID, }, nil }, }, - Args: "--key foobar --service-id 123", - WantOutput: "Purged key: foobar (soft: false). Status: ok, ID: 123", + Args: "--key " + testKey + " --service-id " + testServiceID, + WantOutput: "Purged key: " + testKey + " (soft: false). Status: " + testStatus + ", ID: " + testPurgeID, }, { - Name: "validate PurgeKey API success with soft purge", + Name: "validate PurgeKeys API success with soft purge", API: mock.API{ - PurgeKeyFn: func(_ context.Context, _ *fastly.PurgeKeyInput) (*fastly.Purge, error) { - return &fastly.Purge{ - Status: fastly.ToPointer("ok"), - PurgeID: fastly.ToPointer("123"), + PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { + return map[string]string{ + testKey: testPurgeID, }, nil }, }, - Args: "--key foobar --service-id 123 --soft", - WantOutput: "Purged key: foobar (soft: true). Status: ok, ID: 123", + Args: "--key " + testKey + " --service-id " + testServiceID + " --soft", + WantOutput: "Purged key: " + testKey + " (soft: true). Status: " + testStatus + ", ID: " + testPurgeID, }, } @@ -151,6 +171,13 @@ func TestPurgeKey(t *testing.T) { } func TestPurgeURL(t *testing.T) { + const ( + testServiceID = "123" + testURL = "https://example.com" + testPurgeID = "123" + testStatus = "ok" + ) + scenarios := []testutil.CLIScenario{ { Name: "validate Purge API error", @@ -159,7 +186,7 @@ func TestPurgeURL(t *testing.T) { return nil, testutil.Err }, }, - Args: "--service-id 123 --url https://example.com", + Args: "--service-id " + testServiceID + " --url " + testURL, WantError: testutil.Err.Error(), }, { @@ -167,26 +194,26 @@ func TestPurgeURL(t *testing.T) { API: mock.API{ PurgeFn: func(_ context.Context, _ *fastly.PurgeInput) (*fastly.Purge, error) { return &fastly.Purge{ - Status: fastly.ToPointer("ok"), - PurgeID: fastly.ToPointer("123"), + Status: fastly.ToPointer(testStatus), + PurgeID: fastly.ToPointer(testPurgeID), }, nil }, }, - Args: "--service-id 123 --url https://example.com", - WantOutput: "Purged URL: https://example.com (soft: false). Status: ok, ID: 123", + Args: "--service-id " + testServiceID + " --url " + testURL, + WantOutput: "Purged URL: " + testURL + " (soft: false). Status: " + testStatus + ", ID: " + testPurgeID, }, { Name: "validate Purge API success with soft purge", API: mock.API{ PurgeFn: func(_ context.Context, _ *fastly.PurgeInput) (*fastly.Purge, error) { return &fastly.Purge{ - Status: fastly.ToPointer("ok"), - PurgeID: fastly.ToPointer("123"), + Status: fastly.ToPointer(testStatus), + PurgeID: fastly.ToPointer(testPurgeID), }, nil }, }, - Args: "--service-id 123 --soft --url https://example.com", - WantOutput: "Purged URL: https://example.com (soft: true). Status: ok, ID: 123", + Args: "--service-id " + testServiceID + " --soft --url " + testURL, + WantOutput: "Purged URL: " + testURL + " (soft: true). Status: " + testStatus + ", ID: " + testPurgeID, }, } diff --git a/pkg/commands/purge/root.go b/pkg/commands/purge/root.go index ff9c49606..aa327485d 100644 --- a/pkg/commands/purge/root.go +++ b/pkg/commands/purge/root.go @@ -149,6 +149,34 @@ func (c *RootCommand) purgeAll(serviceID string, out io.Writer) error { return nil } +// purgeKey now uses the bulk purge endpoint to avoid serialization of the 'key' field values. +// This serialization occurs due to the nature of the POST /service/{service_id}/purge/{surrogate_key} +// endpoint storing the 'key' as part of the URL. +func (c *RootCommand) purgeKey(serviceID string, out io.Writer) error { + m, err := c.Globals.APIClient.PurgeKeys(context.TODO(), &fastly.PurgeKeysInput{ + ServiceID: serviceID, + Keys: []string{c.key}, + Soft: c.soft, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Key": c.key, + "Soft": c.soft, + }) + return err + } + purgeID, ok := m[c.key] + if !ok { + return fmt.Errorf("no purge ID returned for key: %s", c.key) + } + // The bulk purge endpoint doesn't return a 'Status' field like the single-key + // endpoint did. To avoid a breaking change in the CLI output, we hardcode + // 'Status: ok' in the success message to maintain consistent behavior. + text.Success(out, "Purged key: %s (soft: %t). Status: ok, ID: %s", c.key, c.soft, purgeID) + return nil +} + func (c *RootCommand) purgeKeys(serviceID string, out io.Writer) error { keys, err := populateKeys(c.file, c.Globals.ErrLog) if err != nil { @@ -158,6 +186,10 @@ func (c *RootCommand) purgeKeys(serviceID string, out io.Writer) error { return err } + return c.purgeBulkKeys(serviceID, keys, out) +} + +func (c *RootCommand) purgeBulkKeys(serviceID string, keys []string, out io.Writer) error { m, err := c.Globals.APIClient.PurgeKeys(context.TODO(), &fastly.PurgeKeysInput{ ServiceID: serviceID, Keys: keys, @@ -188,24 +220,6 @@ func (c *RootCommand) purgeKeys(serviceID string, out io.Writer) error { return nil } -func (c *RootCommand) purgeKey(serviceID string, out io.Writer) error { - p, err := c.Globals.APIClient.PurgeKey(context.TODO(), &fastly.PurgeKeyInput{ - ServiceID: serviceID, - Key: c.key, - Soft: c.soft, - }) - if err != nil { - c.Globals.ErrLog.AddWithContext(err, map[string]any{ - "Service ID": serviceID, - "Key": c.key, - "Soft": c.soft, - }) - return err - } - text.Success(out, "Purged key: %s (soft: %t). Status: %s, ID: %s", c.key, c.soft, fastly.ToValue(p.Status), fastly.ToValue(p.PurgeID)) - return nil -} - func (c *RootCommand) purgeURL(out io.Writer) error { p, err := c.Globals.APIClient.Purge(context.TODO(), &fastly.PurgeInput{ URL: c.url,