Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
101 changes: 64 additions & 37 deletions pkg/commands/purge/purge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import (
)

func TestPurgeAll(t *testing.T) {
const (
testServiceID = "123"
testStatus = "ok"
)

scenarios := []testutil.CLIScenario{
{
Name: "validate missing --service-id flag",
Expand All @@ -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",
},
{
Expand All @@ -31,27 +36,37 @@ func TestPurgeAll(t *testing.T) {
return nil, testutil.Err
},
},
Args: "--all --service-id 123",
Args: "--all --service-id " + testServiceID,
WantError: testutil.Err.Error(),
},
{
Name: "validate PurgeAll API success",
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,
},
}

testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios)
}

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{
{
Expand All @@ -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(),
},
{
Expand All @@ -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",
},
}
Expand All @@ -103,54 +118,66 @@ 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,
},
}

testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios)
}

func TestPurgeURL(t *testing.T) {
const (
testServiceID = "123"
testURL = "https://example.com"
testPurgeID = "123"
testStatus = "ok"
)

scenarios := []testutil.CLIScenario{
{
Name: "validate Purge API error",
Expand All @@ -159,34 +186,34 @@ 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(),
},
{
Name: "validate Purge API success",
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,
},
}

Expand Down
50 changes: 32 additions & 18 deletions pkg/commands/purge/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading