diff --git a/CHANGELOG.md b/CHANGELOG.md index 749975227..7a7fea799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ ### Enhancements: - feat(commands/ngwaf/workspaces): add support for update operation for NGWAF workspaces ([#1578](https://github.com/fastly/cli/pull/1578)) -- feat(commands/ngwaf/lists): add support for CRUD operations for NGWAF Lists ([#1582](https://github.com/fastly/cli/pull/1582)) +- feat(commands/ngwaf/lists): add support for CRUD operations for NGWAF Lists at account and workspace levels ([#1582](https://github.com/fastly/cli/pull/1582)) +- feat(commands/ngwaf/workspaces/alerts): add support for operations for NGWAF alerts ([#1589](https://github.com/fastly/cli/pull/1589)) - feat(commands/ngwaf/customsignals): add support for CRUD operations for NGWAF Custom Signals ([#1592](https://github.com/fastly/cli/pull/1592)) ### Bug fixes: diff --git a/pkg/argparser/common.go b/pkg/argparser/common.go index 5a92eb7fe..4b15fc647 100644 --- a/pkg/argparser/common.go +++ b/pkg/argparser/common.go @@ -9,6 +9,10 @@ var ( FlagJSONName = "json" // FlagJSONDesc is the flag description. FlagJSONDesc = "Render output as JSON" + // FlagNGWAFAlertID is the alert ID. + FlagNGWAFAlertID = "alert-id" + // FlagNGWAFAlertIDDesc is the alert ID flag description. + FlagNGWAFAlertIDDesc = "Alphanumeric string identifying the alert" // FlagNGWAFWorkspaceID is the workspace ID. FlagNGWAFWorkspaceID = "workspace-id" // FlagNGWAFWorkspaceIDDesc is the workspace ID flag description. diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 9c28e6e88..1b2f0295e 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -63,6 +63,15 @@ import ( "github.com/fastly/cli/pkg/commands/ngwaf/stringlist" "github.com/fastly/cli/pkg/commands/ngwaf/wildcardlist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + workspaceAlertDatadog "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/datadog" + workspaceAlertJira "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/jira" + workspaceAlertMailinglist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/mailinglist" + workspaceAlertMicrosoftteams "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/microsoftteams" + workspaceAlertOpsgenie "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/opsgenie" + workspaceAlertPagerduty "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/pagerduty" + workspaceAlertSlack "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/slack" + workspaceAlertWebhook "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/webhook" wscountrylist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/countrylist" wscustomsignal "github.com/fastly/cli/pkg/commands/ngwaf/workspace/customsignal" wsiplist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/iplist" @@ -498,6 +507,57 @@ func Define( // nolint:revive // function-length ngwafVirtualpatchList := virtualpatch.NewListCommand(ngwafVirtualpatchRoot.CmdClause, data) ngwafVirtualpatchUpdate := virtualpatch.NewUpdateCommand(ngwafVirtualpatchRoot.CmdClause, data) ngwafVirtualpatchRetrieve := virtualpatch.NewRetrieveCommand(ngwafVirtualpatchRoot.CmdClause, data) + ngwafWorkspaceAlertRoot := alert.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) + ngwafWorkspaceAlertDatadogRoot := workspaceAlertDatadog.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertDatadogCreate := workspaceAlertDatadog.NewCreateCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) + ngwafWorkspaceAlertDatadogDelete := workspaceAlertDatadog.NewDeleteCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) + ngwafWorkspaceAlertDatadogGet := workspaceAlertDatadog.NewGetCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) + ngwafWorkspaceAlertDatadogList := workspaceAlertDatadog.NewListCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) + ngwafWorkspaceAlertDatadogUpdate := workspaceAlertDatadog.NewUpdateCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) + ngwafWorkspaceAlertJiraRoot := workspaceAlertJira.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertJiraCreate := workspaceAlertJira.NewCreateCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) + ngwafWorkspaceAlertJiraDelete := workspaceAlertJira.NewDeleteCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) + ngwafWorkspaceAlertJiraGet := workspaceAlertJira.NewGetCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) + ngwafWorkspaceAlertJiraList := workspaceAlertJira.NewListCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) + ngwafWorkspaceAlertJiraUpdate := workspaceAlertJira.NewUpdateCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) + ngwafWorkspaceAlertMailinglistRoot := workspaceAlertMailinglist.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertMailinglistCreate := workspaceAlertMailinglist.NewCreateCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) + ngwafWorkspaceAlertMailinglistDelete := workspaceAlertMailinglist.NewDeleteCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) + ngwafWorkspaceAlertMailinglistGet := workspaceAlertMailinglist.NewGetCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) + ngwafWorkspaceAlertMailinglistList := workspaceAlertMailinglist.NewListCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) + ngwafWorkspaceAlertMailinglistUpdate := workspaceAlertMailinglist.NewUpdateCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) + ngwafWorkspaceAlertMicrosoftteamsRoot := workspaceAlertMicrosoftteams.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertMicrosoftteamsCreate := workspaceAlertMicrosoftteams.NewCreateCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) + ngwafWorkspaceAlertMicrosoftteamsDelete := workspaceAlertMicrosoftteams.NewDeleteCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) + ngwafWorkspaceAlertMicrosoftteamsGet := workspaceAlertMicrosoftteams.NewGetCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) + ngwafWorkspaceAlertMicrosoftteamsList := workspaceAlertMicrosoftteams.NewListCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) + ngwafWorkspaceAlertMicrosoftteamsUpdate := workspaceAlertMicrosoftteams.NewUpdateCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) + ngwafWorkspaceAlertOpsgenieRoot := workspaceAlertOpsgenie.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertOpsgenieCreate := workspaceAlertOpsgenie.NewCreateCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) + ngwafWorkspaceAlertOpsgenieDelete := workspaceAlertOpsgenie.NewDeleteCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) + ngwafWorkspaceAlertOpsgenieGet := workspaceAlertOpsgenie.NewGetCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) + ngwafWorkspaceAlertOpsgenieList := workspaceAlertOpsgenie.NewListCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) + ngwafWorkspaceAlertOpsgenieUpdate := workspaceAlertOpsgenie.NewUpdateCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) + ngwafWorkspaceAlertPagerdutyRoot := workspaceAlertPagerduty.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertPagerdutyCreate := workspaceAlertPagerduty.NewCreateCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) + ngwafWorkspaceAlertPagerdutyDelete := workspaceAlertPagerduty.NewDeleteCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) + ngwafWorkspaceAlertPagerdutyGet := workspaceAlertPagerduty.NewGetCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) + ngwafWorkspaceAlertPagerdutyList := workspaceAlertPagerduty.NewListCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) + ngwafWorkspaceAlertPagerdutyUpdate := workspaceAlertPagerduty.NewUpdateCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) + ngwafWorkspaceAlertSlackRoot := workspaceAlertSlack.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertSlackCreate := workspaceAlertSlack.NewCreateCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) + ngwafWorkspaceAlertSlackDelete := workspaceAlertSlack.NewDeleteCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) + ngwafWorkspaceAlertSlackGet := workspaceAlertSlack.NewGetCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) + ngwafWorkspaceAlertSlackList := workspaceAlertSlack.NewListCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) + ngwafWorkspaceAlertSlackUpdate := workspaceAlertSlack.NewUpdateCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookRoot := workspaceAlertWebhook.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookCreate := workspaceAlertWebhook.NewCreateCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookDelete := workspaceAlertWebhook.NewDeleteCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookGet := workspaceAlertWebhook.NewGetCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookGetSigningKey := workspaceAlertWebhook.NewGetSigningKeyCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookList := workspaceAlertWebhook.NewListCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookRotateSigningKey := workspaceAlertWebhook.NewRotateSigningKeyCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) + ngwafWorkspaceAlertWebhookUpdate := workspaceAlertWebhook.NewUpdateCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) objectStorageRoot := objectstorage.NewRootCommand(app, data) objectStorageAccesskeysRoot := accesskeys.NewRootCommand(objectStorageRoot.CmdClause, data) objectStorageAccesskeysCreate := accesskeys.NewCreateCommand(objectStorageAccesskeysRoot.CmdClause, data) @@ -998,6 +1058,57 @@ func Define( // nolint:revive // function-length ngwafVirtualpatchRetrieve, ngwafVirtualpatchRoot, ngwafVirtualpatchUpdate, + ngwafWorkspaceAlertRoot, + ngwafWorkspaceAlertDatadogRoot, + ngwafWorkspaceAlertDatadogCreate, + ngwafWorkspaceAlertDatadogDelete, + ngwafWorkspaceAlertDatadogGet, + ngwafWorkspaceAlertDatadogList, + ngwafWorkspaceAlertDatadogUpdate, + ngwafWorkspaceAlertJiraRoot, + ngwafWorkspaceAlertJiraCreate, + ngwafWorkspaceAlertJiraDelete, + ngwafWorkspaceAlertJiraGet, + ngwafWorkspaceAlertJiraList, + ngwafWorkspaceAlertJiraUpdate, + ngwafWorkspaceAlertMailinglistRoot, + ngwafWorkspaceAlertMailinglistCreate, + ngwafWorkspaceAlertMailinglistDelete, + ngwafWorkspaceAlertMailinglistGet, + ngwafWorkspaceAlertMailinglistList, + ngwafWorkspaceAlertMailinglistUpdate, + ngwafWorkspaceAlertMicrosoftteamsRoot, + ngwafWorkspaceAlertMicrosoftteamsCreate, + ngwafWorkspaceAlertMicrosoftteamsDelete, + ngwafWorkspaceAlertMicrosoftteamsGet, + ngwafWorkspaceAlertMicrosoftteamsList, + ngwafWorkspaceAlertMicrosoftteamsUpdate, + ngwafWorkspaceAlertOpsgenieRoot, + ngwafWorkspaceAlertOpsgenieCreate, + ngwafWorkspaceAlertOpsgenieDelete, + ngwafWorkspaceAlertOpsgenieGet, + ngwafWorkspaceAlertOpsgenieList, + ngwafWorkspaceAlertOpsgenieUpdate, + ngwafWorkspaceAlertPagerdutyRoot, + ngwafWorkspaceAlertPagerdutyCreate, + ngwafWorkspaceAlertPagerdutyDelete, + ngwafWorkspaceAlertPagerdutyGet, + ngwafWorkspaceAlertPagerdutyList, + ngwafWorkspaceAlertPagerdutyUpdate, + ngwafWorkspaceAlertSlackRoot, + ngwafWorkspaceAlertSlackCreate, + ngwafWorkspaceAlertSlackDelete, + ngwafWorkspaceAlertSlackGet, + ngwafWorkspaceAlertSlackList, + ngwafWorkspaceAlertSlackUpdate, + ngwafWorkspaceAlertWebhookRoot, + ngwafWorkspaceAlertWebhookCreate, + ngwafWorkspaceAlertWebhookDelete, + ngwafWorkspaceAlertWebhookGet, + ngwafWorkspaceAlertWebhookGetSigningKey, + ngwafWorkspaceAlertWebhookList, + ngwafWorkspaceAlertWebhookRotateSigningKey, + ngwafWorkspaceAlertWebhookUpdate, ngwafWorkspaceRoot, ngwafWorkspaceCreate, ngwafWorkspaceDelete, diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/create.go b/pkg/commands/ngwaf/workspace/alert/datadog/create.go new file mode 100644 index 000000000..d1fc860fa --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/create.go @@ -0,0 +1,98 @@ +package datadog + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" +) + +// CreateCommand calls the Fastly API to create Datadog alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Key string + Site string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Datadog alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("key", "Datadog integration key.").Required().StringVar(&c.Key) + c.CmdClause.Flag("site", "Datadog site.").Required().StringVar(&c.Site) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &datadog.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &datadog.CreateConfig{ + Key: &c.Key, + Site: &c.Site, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := datadog.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/datadog_test.go b/pkg/commands/ngwaf/workspace/alert/datadog/datadog_test.go new file mode 100644 index 000000000..3f1791d3f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/datadog_test.go @@ -0,0 +1,434 @@ +package datadog_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/datadog" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" +) + +const ( + alertID = "7890abcdef12345678901234" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestDatadogAlert" +) + +var ( + key = "a1b2c3d4e5f67890abcdef1234567890" + site = "datadoghq.com" + datadogAlert = datadog.Alert{ + ID: alertID, + Type: "datadog", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: datadog.ResponseConfig{ + Key: &key, + Site: &site, + }, + } +) + +func TestDatadogAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--key %s --site %s", key, site), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --key flag", + Args: fmt.Sprintf("--workspace-id %s --site %s", workspaceID, site), + WantError: "error parsing arguments: required flag --key not provided", + }, + { + Name: "validate missing --site flag", + Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), + WantError: "error parsing arguments: required flag --site not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --key %s --site %s", workspaceID, key, site), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", datadogAlert.Type, datadogAlert.ID, workspaceID), + }, + { + Name: "validate API success with description", + Args: fmt.Sprintf("--workspace-id %s --key %s --site %s --description %s", workspaceID, key, site, description), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", datadogAlert.Type, datadogAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --key %s --site %s --json", workspaceID, key, site), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(datadogAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestDatadogAlertList(t *testing.T) { + alertsObject := datadog.Alerts{ + Data: []datadog.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "datadog", + Description: "First Datadog alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: datadog.ResponseConfig{ + Key: &key, + Site: &site, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "datadog", + Description: "Second Datadog alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: datadog.ResponseConfig{ + Key: &key, + Site: &site, + }, + }, + }, + Meta: datadog.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(datadog.Alerts{ + Data: []datadog.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestDatadogAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(datadogAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestDatadogAlertUpdate(t *testing.T) { + updatedKey := "updated-key-9876543210" + updatedSite := "datadoghq.eu" + updatedDescription := "Updated description" + updatedAlert := datadog.Alert{ + ID: alertID, + Type: "datadog", + Description: updatedDescription, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: datadog.ResponseConfig{ + Key: &updatedKey, + Site: &updatedSite, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --key %s --site %s", alertID, key, site), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --key %s --site %s", workspaceID, key, site), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --key updated-key-9876543210 --site datadoghq.eu", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with key and site", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210 --site datadoghq.eu", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + // First response for GET (fetching current alert) + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + // Second response for PATCH (updating alert) + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(updatedAlert)))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210 --site datadoghq.eu --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + // First response for GET (fetching current alert) + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), + }, + // Second response for PATCH (updating alert) + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(updatedAlert)))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestDatadogAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 7890abcdef12345678901234 +Type: datadog +Description: TestDatadogAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Key: + Site: datadoghq.com +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 datadog First Datadog alert 2025-11-25T16:40:12Z test@example.com Site: datadoghq.com, Key: +2b3c4d5e6f7890abcdef1234 datadog Second Datadog alert 2025-11-25T16:40:12Z test@example.com Site: datadoghq.com, Key: +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/delete.go b/pkg/commands/ngwaf/workspace/alert/datadog/delete.go new file mode 100644 index 000000000..769204a9f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/delete.go @@ -0,0 +1,92 @@ +package datadog + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" +) + +// DeleteCommand calls the Fastly API to delete Datadog alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Datadog alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := datadog.Delete(context.TODO(), fc, &datadog.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/doc.go b/pkg/commands/ngwaf/workspace/alert/datadog/doc.go new file mode 100644 index 000000000..717e21995 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/doc.go @@ -0,0 +1,2 @@ +// Package datadog contains commands to inspect and manipulate NGWAF Datadog alerts. +package datadog diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/get.go b/pkg/commands/ngwaf/workspace/alert/datadog/get.go new file mode 100644 index 000000000..1a64ffeaa --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/get.go @@ -0,0 +1,86 @@ +package datadog + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" +) + +// GetCommand calls the Fastly API to get Datadog alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Datadog alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &datadog.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := datadog.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/list.go b/pkg/commands/ngwaf/workspace/alert/datadog/list.go new file mode 100644 index 000000000..cfbfc23f4 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/list.go @@ -0,0 +1,78 @@ +package datadog + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" +) + +// ListCommand calls the Fastly API to list Datadog alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Datadog alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &datadog.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := datadog.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/root.go b/pkg/commands/ngwaf/workspace/alert/datadog/root.go new file mode 100644 index 000000000..55a216952 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/root.go @@ -0,0 +1,37 @@ +package datadog + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "datadog" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Datadog workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} + +// ConfigFlags contains Datadog specific configuration flags. +type ConfigFlags struct { + Key string + Site string +} diff --git a/pkg/commands/ngwaf/workspace/alert/datadog/update.go b/pkg/commands/ngwaf/workspace/alert/datadog/update.go new file mode 100644 index 000000000..1b375190a --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/datadog/update.go @@ -0,0 +1,98 @@ +package datadog + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" +) + +// UpdateCommand calls the Fastly API to update Datadog alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Key string + Site string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Datadog alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("key", "Datadog integration key.").Required().StringVar(&c.Key) + c.CmdClause.Flag("site", "Datadog site.").Required().StringVar(&c.Site) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + input := &datadog.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &datadog.UpdateConfig{ + Key: &c.Key, + Site: &c.Site, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := datadog.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/doc.go b/pkg/commands/ngwaf/workspace/alert/doc.go new file mode 100644 index 000000000..b8072fb1f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/doc.go @@ -0,0 +1,2 @@ +// Package alert contains commands to inspect and manipulate NGWAF alerts. +package alert diff --git a/pkg/commands/ngwaf/workspace/alert/jira/create.go b/pkg/commands/ngwaf/workspace/alert/jira/create.go new file mode 100644 index 000000000..6877c5731 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/create.go @@ -0,0 +1,108 @@ +package jira + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" +) + +// CreateCommand calls the Fastly API to create Jira alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Host string + Key string + Project string + Username string + + // Optional. + Description argparser.OptionalString + IssueType string +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Jira alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("host", "Host name of the Jira instance.").Required().StringVar(&c.Host) + c.CmdClause.Flag("key", "Jira API key.").Required().StringVar(&c.Key) + c.CmdClause.Flag("project", "Specifies the Jira project where the issue will be created.").Required().StringVar(&c.Project) + c.CmdClause.Flag("username", "Jira username of the user who created the ticket.").Required().StringVar(&c.Username) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.CmdClause.Flag("issue-type", "An optional Jira issue type associated with the ticket. (Default Task)").StringVar(&c.IssueType) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &jira.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &jira.CreateConfig{ + Host: &c.Host, + Key: &c.Key, + Project: &c.Project, + Username: &c.Username, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.IssueType != "" { + input.Config.IssueType = &c.IssueType + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := jira.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/jira/delete.go b/pkg/commands/ngwaf/workspace/alert/jira/delete.go new file mode 100644 index 000000000..0f96500ac --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/delete.go @@ -0,0 +1,92 @@ +package jira + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" +) + +// DeleteCommand calls the Fastly API to delete Jira alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Jira alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := jira.Delete(context.TODO(), fc, &jira.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/jira/doc.go b/pkg/commands/ngwaf/workspace/alert/jira/doc.go new file mode 100644 index 000000000..79434c647 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/doc.go @@ -0,0 +1,2 @@ +// Package jira contains commands to inspect and manipulate NGWAF Jira alerts. +package jira diff --git a/pkg/commands/ngwaf/workspace/alert/jira/get.go b/pkg/commands/ngwaf/workspace/alert/jira/get.go new file mode 100644 index 000000000..8d709b012 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/get.go @@ -0,0 +1,86 @@ +package jira + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" +) + +// GetCommand calls the Fastly API to get Jira alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Jira alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &jira.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := jira.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/jira/jira_test.go b/pkg/commands/ngwaf/workspace/alert/jira/jira_test.go new file mode 100644 index 000000000..4376917f0 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/jira_test.go @@ -0,0 +1,474 @@ +package jira_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/jira" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" +) + +const ( + alertID = "890abcdef1234567890123ab" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestJiraAlert" +) + +var ( + host = "example.atlassian.net" + key = "jira-api-key-123456" + project = "PROJ" + username = "user@example.com" + issueType = "Task" + jiraAlert = jira.Alert{ + ID: alertID, + Type: "jira", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: jira.ResponseConfig{ + Host: &host, + Key: &key, + Project: &project, + Username: &username, + IssueType: &issueType, + }, + } +) + +func TestJiraAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--host %s --key %s --project %s --username %s", host, key, project, username), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --host flag", + Args: fmt.Sprintf("--workspace-id %s --key %s --project %s --username %s", workspaceID, key, project, username), + WantError: "error parsing arguments: required flag --host not provided", + }, + { + Name: "validate missing --key flag", + Args: fmt.Sprintf("--workspace-id %s --host %s --project %s --username %s", workspaceID, host, project, username), + WantError: "error parsing arguments: required flag --key not provided", + }, + { + Name: "validate missing --project flag", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --username %s", workspaceID, host, key, username), + WantError: "error parsing arguments: required flag --project not provided", + }, + { + Name: "validate missing --username flag", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s", workspaceID, host, key, project), + WantError: "error parsing arguments: required flag --username not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s", workspaceID, host, key, project, username), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", jiraAlert.Type, jiraAlert.ID, workspaceID), + }, + { + Name: "validate API success with description", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s --description %s", workspaceID, host, key, project, username, description), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", jiraAlert.Type, jiraAlert.ID, workspaceID), + }, + { + Name: "validate API success with issue-type", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s --issue-type %s", workspaceID, host, key, project, username, issueType), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", jiraAlert.Type, jiraAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s --json", workspaceID, host, key, project, username), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(jiraAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestJiraAlertList(t *testing.T) { + alertsObject := jira.Alerts{ + Data: []jira.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "jira", + Description: "First Jira alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: jira.ResponseConfig{ + Host: &host, + Key: &key, + Project: &project, + Username: &username, + IssueType: &issueType, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "jira", + Description: "Second Jira alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: jira.ResponseConfig{ + Host: &host, + Key: &key, + Project: &project, + Username: &username, + IssueType: &issueType, + }, + }, + }, + Meta: jira.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(jira.Alerts{ + Data: []jira.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestJiraAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(jiraAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestJiraAlertUpdate(t *testing.T) { + updatedHost := "updated.atlassian.net" + updatedKey := "updated-jira-key-456" + updatedProject := "UPDT" + updatedUsername := "updated@example.com" + updatedIssueType := "Bug" + updatedAlert := jira.Alert{ + ID: alertID, + Type: "jira", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: jira.ResponseConfig{ + Host: &updatedHost, + Key: &updatedKey, + Project: &updatedProject, + Username: &updatedUsername, + IssueType: &updatedIssueType, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --host %s --key %s --project %s --username %s", alertID, host, key, project, username), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s", workspaceID, host, key, project, username), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --host updated.atlassian.net --key updated-jira-key-456 --project UPDT --username updated@example.com", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with all config fields", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --host updated.atlassian.net --key updated-jira-key-456 --project UPDT --username updated@example.com --issue-type Bug", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(jiraAlert))), + }, + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --host updated.atlassian.net --key updated-jira-key-456 --project UPDT --username updated@example.com --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(jiraAlert))), + }, + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestJiraAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 890abcdef1234567890123ab +Type: jira +Description: TestJiraAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Host: example.atlassian.net + Username: user@example.com + Project: PROJ + Issue Type: Task + Key: +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 jira First Jira alert 2025-11-25T16:40:12Z test@example.com Host: example.atlassian.net, Issue Type: Task, Key: , Project: PROJ, Username: user@example.com +2b3c4d5e6f7890abcdef1234 jira Second Jira alert 2025-11-25T16:40:12Z test@example.com Host: example.atlassian.net, Issue Type: Task, Key: , Project: PROJ, Username: user@example.com +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/jira/list.go b/pkg/commands/ngwaf/workspace/alert/jira/list.go new file mode 100644 index 000000000..34be07113 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/list.go @@ -0,0 +1,78 @@ +package jira + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" +) + +// ListCommand calls the Fastly API to list Jira alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Jira alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &jira.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := jira.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/jira/root.go b/pkg/commands/ngwaf/workspace/alert/jira/root.go new file mode 100644 index 000000000..e97fcf0e8 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/root.go @@ -0,0 +1,44 @@ +package jira + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "jira" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Jira workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} + +// ConfigFlags contains Jira specific configuration flags. +type ConfigFlags struct { + Host string + Key string + Project string + Username string +} + +// OptConfigFlags contains optional Jira specific configuration flags. +type OptConfigFlags struct { + IssueType string +} diff --git a/pkg/commands/ngwaf/workspace/alert/jira/update.go b/pkg/commands/ngwaf/workspace/alert/jira/update.go new file mode 100644 index 000000000..9533ed4c2 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/jira/update.go @@ -0,0 +1,111 @@ +package jira + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" +) + +// UpdateCommand calls the Fastly API to update Jira alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Host string + Key string + Project string + Username string + + // Optional + IssueType string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Jira alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("host", "Host name of the Jira instance.").Required().StringVar(&c.Host) + c.CmdClause.Flag("key", "Jira API key.").Required().StringVar(&c.Key) + c.CmdClause.Flag("project", "Specifies the Jira project where the issue will be created.").Required().StringVar(&c.Project) + c.CmdClause.Flag("username", "Jira username of the user who created the ticket.").Required().StringVar(&c.Username) + + // Optional. + c.CmdClause.Flag("issue-type", "An optional Jira issue type associated with the ticket. (Default Task)").StringVar(&c.IssueType) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &jira.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &jira.UpdateConfig{ + Host: &c.Host, + Key: &c.Key, + Project: &c.Project, + Username: &c.Username, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.IssueType != "" { + input.Config.IssueType = &c.IssueType + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := jira.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/create.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/create.go new file mode 100644 index 000000000..1eecaf4d2 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/create.go @@ -0,0 +1,95 @@ +package mailinglist + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" +) + +// CreateCommand calls the Fastly API to create Mailing List alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Address string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Mailing List alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("address", "An email address.").Required().StringVar(&c.Address) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &mailinglist.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &mailinglist.CreateConfig{ + Address: &c.Address, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := mailinglist.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/delete.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/delete.go new file mode 100644 index 000000000..61f740260 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/delete.go @@ -0,0 +1,92 @@ +package mailinglist + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" +) + +// DeleteCommand calls the Fastly API to delete Mailing List alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Mailing List alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := mailinglist.Delete(context.TODO(), fc, &mailinglist.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/doc.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/doc.go new file mode 100644 index 000000000..a5a5a45f1 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/doc.go @@ -0,0 +1,2 @@ +// Package mailinglist contains commands to inspect and manipulate NGWAF Mailing List alerts. +package mailinglist diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/get.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/get.go new file mode 100644 index 000000000..e0be7241a --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/get.go @@ -0,0 +1,86 @@ +package mailinglist + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" +) + +// GetCommand calls the Fastly API to get Mailing List alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Mailing List alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &mailinglist.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := mailinglist.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/list.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/list.go new file mode 100644 index 000000000..ee1046b6b --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/list.go @@ -0,0 +1,78 @@ +package mailinglist + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" +) + +// ListCommand calls the Fastly API to list Mailing List alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Mailing List alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &mailinglist.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := mailinglist.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/mailinglist_test.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/mailinglist_test.go new file mode 100644 index 000000000..fed3b8e19 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/mailinglist_test.go @@ -0,0 +1,415 @@ +package mailinglist_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/mailinglist" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" +) + +const ( + alertID = "2b3c4d5e6f7890abcdef1234" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestMailingListAlert" +) + +var ( + address = "alerts@example.com" + alertAddress1 = "alerts1@example.com" + alertAddress2 = "alerts2@example.com" + mailinglistAlert = mailinglist.Alert{ + ID: alertID, + Type: "mailinglist", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: mailinglist.ResponseConfig{ + Address: &address, + }, + } +) + +func TestMailingListAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--address %s", address), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --address flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --address not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --address %s", workspaceID, address), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", mailinglistAlert.Type, mailinglistAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --address %s --json", workspaceID, address), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(mailinglistAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestMailingListAlertList(t *testing.T) { + alertsObject := mailinglist.Alerts{ + Data: []mailinglist.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "mailinglist", + Description: "First mailing list alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: mailinglist.ResponseConfig{ + Address: &alertAddress1, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "mailinglist", + Description: "Second mailing list alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: mailinglist.ResponseConfig{ + Address: &alertAddress2, + }, + }, + }, + Meta: mailinglist.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(mailinglist.Alerts{ + Data: []mailinglist.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestMailingListAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(mailinglistAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestMailingListAlertUpdate(t *testing.T) { + updatedAddress := "updated@example.com" + updatedAlert := mailinglist.Alert{ + ID: alertID, + Type: "mailingList", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: mailinglist.ResponseConfig{ + Address: &updatedAddress, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --address %s", alertID, address), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --address %s", workspaceID, address), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --address updated@example.com", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with address", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --address updated@example.com", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(mailinglistAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --address updated@example.com --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(mailinglistAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestMailingListAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 2b3c4d5e6f7890abcdef1234 +Type: mailinglist +Description: TestMailingListAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Address: alerts@example.com +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 mailinglist First mailing list alert 2025-11-25T16:40:12Z test@example.com Address: alerts1@example.com +2b3c4d5e6f7890abcdef1234 mailinglist Second mailing list alert 2025-11-25T16:40:12Z test@example.com Address: alerts2@example.com +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/root.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/root.go new file mode 100644 index 000000000..6a374e477 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/root.go @@ -0,0 +1,36 @@ +package mailinglist + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "mailinglist" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Mailing List workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} + +// AddressConfigFlags contains Address configurations used by mailing lists. +type AddressConfigFlags struct { + Address string +} diff --git a/pkg/commands/ngwaf/workspace/alert/mailinglist/update.go b/pkg/commands/ngwaf/workspace/alert/mailinglist/update.go new file mode 100644 index 000000000..8dab37876 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/mailinglist/update.go @@ -0,0 +1,96 @@ +package mailinglist + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" +) + +// UpdateCommand calls the Fastly API to update Mailing List alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Address string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Mailing List alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("address", "An email address.").Required().StringVar(&c.Address) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &mailinglist.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &mailinglist.UpdateConfig{ + Address: &c.Address, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := mailinglist.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/create.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/create.go new file mode 100644 index 000000000..331e6be5f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/create.go @@ -0,0 +1,95 @@ +package microsoftteams + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" +) + +// CreateCommand calls the Fastly API to create Microsoft Teams alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Webhook string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Microsoft Teams alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("webhook", "Microsoft Teams webhook.").Required().StringVar(&c.Webhook) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := µsoftteams.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: µsoftteams.CreateConfig{ + Webhook: &c.Webhook, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := microsoftteams.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/delete.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/delete.go new file mode 100644 index 000000000..4a88e1fc0 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/delete.go @@ -0,0 +1,92 @@ +package microsoftteams + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" +) + +// DeleteCommand calls the Fastly API to delete Microsoft Teams alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Microsoft Teams alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := microsoftteams.Delete(context.TODO(), fc, µsoftteams.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/doc.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/doc.go new file mode 100644 index 000000000..86e4e1cb8 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/doc.go @@ -0,0 +1,2 @@ +// Package microsoftteams contains commands to inspect and manipulate NGWAF Microsoft Teams alerts. +package microsoftteams diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/get.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/get.go new file mode 100644 index 000000000..ea915b037 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/get.go @@ -0,0 +1,86 @@ +package microsoftteams + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" +) + +// GetCommand calls the Fastly API to get Microsoft Teams alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Microsoft Teams alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := µsoftteams.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := microsoftteams.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/list.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/list.go new file mode 100644 index 000000000..b0face2e0 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/list.go @@ -0,0 +1,78 @@ +package microsoftteams + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" +) + +// ListCommand calls the Fastly API to list Microsoft Teams alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Microsoft Teams alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := µsoftteams.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := microsoftteams.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/microsoftteams_test.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/microsoftteams_test.go new file mode 100644 index 000000000..764ec132e --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/microsoftteams_test.go @@ -0,0 +1,413 @@ +package microsoftteams_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/microsoftteams" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" +) + +const ( + alertID = "3c4d5e6f7890abcdef123456" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestMicrosoftTeamsAlert" +) + +var ( + webhook = "https://outlook.office.com/webhook/example" + teamsAlert = microsoftteams.Alert{ + ID: alertID, + Type: "microsoftteams", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: microsoftteams.ResponseConfig{ + Webhook: &webhook, + }, + } +) + +func TestMicrosoftTeamsAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--webhook %s", webhook), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --webhook flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --webhook not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", teamsAlert.Type, teamsAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --webhook %s --json", workspaceID, webhook), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(teamsAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestMicrosoftTeamsAlertList(t *testing.T) { + alertsObject := microsoftteams.Alerts{ + Data: []microsoftteams.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "microsoftteams", + Description: "First Microsoft Teams alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: microsoftteams.ResponseConfig{ + Webhook: &webhook, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "microsoftteams", + Description: "Second Microsoft Teams alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: microsoftteams.ResponseConfig{ + Webhook: &webhook, + }, + }, + }, + Meta: microsoftteams.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(microsoftteams.Alerts{ + Data: []microsoftteams.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestMicrosoftTeamsAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(teamsAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestMicrosoftTeamsAlertUpdate(t *testing.T) { + updatedWebhook := "https://outlook.office.com/webhook/updated" + updatedAlert := microsoftteams.Alert{ + ID: alertID, + Type: "microsoftteams", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: microsoftteams.ResponseConfig{ + Webhook: &updatedWebhook, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --webhook %s", alertID, webhook), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --webhook https://outlook.office.com/webhook/updated", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with webhook", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://outlook.office.com/webhook/updated", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(teamsAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://outlook.office.com/webhook/updated --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(teamsAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestMicrosoftTeamsAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 3c4d5e6f7890abcdef123456 +Type: microsoftteams +Description: TestMicrosoftTeamsAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Webhook: +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 microsoftteams First Microsoft Teams alert 2025-11-25T16:40:12Z test@example.com Webhook: +2b3c4d5e6f7890abcdef1234 microsoftteams Second Microsoft Teams alert 2025-11-25T16:40:12Z test@example.com Webhook: +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/root.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/root.go new file mode 100644 index 000000000..773d49152 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/root.go @@ -0,0 +1,31 @@ +package microsoftteams + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "microsoftteams" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Microsoft Teams workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/alert/microsoftteams/update.go b/pkg/commands/ngwaf/workspace/alert/microsoftteams/update.go new file mode 100644 index 000000000..7044cb3c6 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/microsoftteams/update.go @@ -0,0 +1,96 @@ +package microsoftteams + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" +) + +// UpdateCommand calls the Fastly API to update Microsoft Teams alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Webhook string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Microsoft Teams alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("webhook", "Microsoft Teams webhook.").Required().StringVar(&c.Webhook) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := µsoftteams.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: µsoftteams.UpdateConfig{ + Webhook: &c.Webhook, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := microsoftteams.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/create.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/create.go new file mode 100644 index 000000000..04efe5da4 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/create.go @@ -0,0 +1,95 @@ +package opsgenie + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" +) + +// CreateCommand calls the Fastly API to create Opsgenie alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Key string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Opsgenie alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("key", "Opsgenie integration key.").Required().StringVar(&c.Key) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &opsgenie.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &opsgenie.CreateConfig{ + Key: &c.Key, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := opsgenie.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/delete.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/delete.go new file mode 100644 index 000000000..3e157c86a --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/delete.go @@ -0,0 +1,92 @@ +package opsgenie + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" +) + +// DeleteCommand calls the Fastly API to delete Opsgenie alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Opsgenie alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := opsgenie.Delete(context.TODO(), fc, &opsgenie.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/doc.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/doc.go new file mode 100644 index 000000000..5a7b0b054 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/doc.go @@ -0,0 +1,2 @@ +// Package opsgenie contains commands to inspect and manipulate NGWAF Opsgenie alerts. +package opsgenie diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/get.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/get.go new file mode 100644 index 000000000..ee196d1a7 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/get.go @@ -0,0 +1,86 @@ +package opsgenie + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" +) + +// GetCommand calls the Fastly API to get Opsgenie alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Opsgenie alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &opsgenie.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := opsgenie.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/list.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/list.go new file mode 100644 index 000000000..4f9728a60 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/list.go @@ -0,0 +1,78 @@ +package opsgenie + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" +) + +// ListCommand calls the Fastly API to list Opsgenie alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Opsgenie alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &opsgenie.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := opsgenie.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/opsgenie_test.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/opsgenie_test.go new file mode 100644 index 000000000..a2d5d8572 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/opsgenie_test.go @@ -0,0 +1,427 @@ +package opsgenie_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/opsgenie" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" +) + +const ( + alertID = "4d5e6f7890abcdef12345678" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestOpsgenieAlert" +) + +var ( + key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + opsgenieAlert = opsgenie.Alert{ + ID: alertID, + Type: "opsgenie", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: opsgenie.ResponseConfig{ + Key: &key, + }, + } +) + +func TestOpsgenieAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--key %s", key), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --key flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --key not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", opsgenieAlert.Type, opsgenieAlert.ID, workspaceID), + }, + { + Name: "validate API success with description", + Args: fmt.Sprintf("--workspace-id %s --key %s --description %s", workspaceID, key, description), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", opsgenieAlert.Type, opsgenieAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --key %s --json", workspaceID, key), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(opsgenieAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestOpsgenieAlertList(t *testing.T) { + alertsObject := opsgenie.Alerts{ + Data: []opsgenie.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "opsgenie", + Description: "First Opsgenie alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: opsgenie.ResponseConfig{ + Key: &key, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "opsgenie", + Description: "Second Opsgenie alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: opsgenie.ResponseConfig{ + Key: &key, + }, + }, + }, + Meta: opsgenie.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(opsgenie.Alerts{ + Data: []opsgenie.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestOpsgenieAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(opsgenieAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestOpsgenieAlertUpdate(t *testing.T) { + updatedKey := "updated-key-1234" + updatedAlert := opsgenie.Alert{ + ID: alertID, + Type: "opsgenie", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: opsgenie.ResponseConfig{ + Key: &updatedKey, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --key %s", alertID, key), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --key updated-key-1234", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with key", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-1234", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(opsgenieAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-1234 --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(opsgenieAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestOpsgenieAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 4d5e6f7890abcdef12345678 +Type: opsgenie +Description: TestOpsgenieAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Key: +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 opsgenie First Opsgenie alert 2025-11-25T16:40:12Z test@example.com Key: +2b3c4d5e6f7890abcdef1234 opsgenie Second Opsgenie alert 2025-11-25T16:40:12Z test@example.com Key: +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/root.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/root.go new file mode 100644 index 000000000..79a53ab72 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/root.go @@ -0,0 +1,31 @@ +package opsgenie + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "opsgenie" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Opsgenie workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/alert/opsgenie/update.go b/pkg/commands/ngwaf/workspace/alert/opsgenie/update.go new file mode 100644 index 000000000..efa2c47d4 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/opsgenie/update.go @@ -0,0 +1,96 @@ +package opsgenie + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" +) + +// UpdateCommand calls the Fastly API to update Opsgenie alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Key string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Opsgenie alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("key", "Opsgenie integration key.").Required().StringVar(&c.Key) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &opsgenie.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &opsgenie.UpdateConfig{ + Key: &c.Key, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := opsgenie.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/create.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/create.go new file mode 100644 index 000000000..d3181507f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/create.go @@ -0,0 +1,95 @@ +package pagerduty + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" +) + +// CreateCommand calls the Fastly API to create PagerDuty alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Key string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a PagerDuty alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("key", "PagerDuty integration key.").Required().StringVar(&c.Key) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &pagerduty.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &pagerduty.CreateConfig{ + Key: &c.Key, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := pagerduty.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/delete.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/delete.go new file mode 100644 index 000000000..7c76c912b --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/delete.go @@ -0,0 +1,92 @@ +package pagerduty + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" +) + +// DeleteCommand calls the Fastly API to delete PagerDuty alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a PagerDuty alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := pagerduty.Delete(context.TODO(), fc, &pagerduty.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/doc.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/doc.go new file mode 100644 index 000000000..6ec40bc20 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/doc.go @@ -0,0 +1,2 @@ +// Package pagerduty contains commands to inspect and manipulate NGWAF PagerDuty alerts. +package pagerduty diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/get.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/get.go new file mode 100644 index 000000000..eea35f0aa --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/get.go @@ -0,0 +1,86 @@ +package pagerduty + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" +) + +// GetCommand calls the Fastly API to get PagerDuty alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a PagerDuty alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &pagerduty.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := pagerduty.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/list.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/list.go new file mode 100644 index 000000000..788139068 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/list.go @@ -0,0 +1,78 @@ +package pagerduty + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" +) + +// ListCommand calls the Fastly API to list PagerDuty alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List PagerDuty alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &pagerduty.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := pagerduty.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/pagerduty_test.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/pagerduty_test.go new file mode 100644 index 000000000..94144ca1f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/pagerduty_test.go @@ -0,0 +1,413 @@ +package pagerduty_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/pagerduty" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" +) + +const ( + alertID = "5e6f7890abcdef1234567890" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestPagerDutyAlert" +) + +var ( + key = "a1b2c3d4e5f67890abcdef1234567890" + pagerdutyAlert = pagerduty.Alert{ + ID: alertID, + Type: "pagerduty", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: pagerduty.ResponseConfig{ + Key: &key, + }, + } +) + +func TestPagerDutyAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--key %s", key), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --key flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --key not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", pagerdutyAlert.Type, pagerdutyAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --key %s --json", workspaceID, key), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(pagerdutyAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestPagerDutyAlertList(t *testing.T) { + alertsObject := pagerduty.Alerts{ + Data: []pagerduty.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "pagerduty", + Description: "First PagerDuty alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: pagerduty.ResponseConfig{ + Key: &key, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "pagerduty", + Description: "Second PagerDuty alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: pagerduty.ResponseConfig{ + Key: &key, + }, + }, + }, + Meta: pagerduty.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(pagerduty.Alerts{ + Data: []pagerduty.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestPagerDutyAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(pagerdutyAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestPagerDutyAlertUpdate(t *testing.T) { + updatedKey := "updated-key-9876543210" + updatedAlert := pagerduty.Alert{ + ID: alertID, + Type: "pagerduty", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: pagerduty.ResponseConfig{ + Key: &updatedKey, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --key %s", alertID, key), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --key updated-key-9876543210", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with key", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(pagerdutyAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210 --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(pagerdutyAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestPagerDutyAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 5e6f7890abcdef1234567890 +Type: pagerduty +Description: TestPagerDutyAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Key: +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 pagerduty First PagerDuty alert 2025-11-25T16:40:12Z test@example.com Key: +2b3c4d5e6f7890abcdef1234 pagerduty Second PagerDuty alert 2025-11-25T16:40:12Z test@example.com Key: +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/root.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/root.go new file mode 100644 index 000000000..b0b85dd6c --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/root.go @@ -0,0 +1,31 @@ +package pagerduty + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "pagerduty" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage PagerDuty workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/alert/pagerduty/update.go b/pkg/commands/ngwaf/workspace/alert/pagerduty/update.go new file mode 100644 index 000000000..00628ad96 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/pagerduty/update.go @@ -0,0 +1,96 @@ +package pagerduty + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" +) + +// UpdateCommand calls the Fastly API to update PagerDuty alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Key string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a PagerDuty alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("key", "PagerDuty integration key.").Required().StringVar(&c.Key) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &pagerduty.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &pagerduty.UpdateConfig{ + Key: &c.Key, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := pagerduty.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/root.go b/pkg/commands/ngwaf/workspace/alert/root.go new file mode 100644 index 000000000..59561832c --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/root.go @@ -0,0 +1,38 @@ +package alert + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "alert" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} + +// GetDefaultEvents returns the hardcoded events value for all alerts. +// Currently the only supported value is "flag". +func GetDefaultEvents() *[]string { + events := []string{"flag"} + return &events +} diff --git a/pkg/commands/ngwaf/workspace/alert/slack/create.go b/pkg/commands/ngwaf/workspace/alert/slack/create.go new file mode 100644 index 000000000..e1bf16a39 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/create.go @@ -0,0 +1,95 @@ +package slack + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" +) + +// CreateCommand calls the Fastly API to create Slack alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Webhook string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Slack alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("webhook", "Slack webhook.").Required().StringVar(&c.Webhook) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &slack.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &slack.CreateConfig{ + Webhook: &c.Webhook, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := slack.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/slack/delete.go b/pkg/commands/ngwaf/workspace/alert/slack/delete.go new file mode 100644 index 000000000..db85825e1 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/delete.go @@ -0,0 +1,92 @@ +package slack + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" +) + +// DeleteCommand calls the Fastly API to delete Slack alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Slack alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := slack.Delete(context.TODO(), fc, &slack.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/slack/doc.go b/pkg/commands/ngwaf/workspace/alert/slack/doc.go new file mode 100644 index 000000000..cc6bc76c1 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/doc.go @@ -0,0 +1,2 @@ +// Package slack contains commands to inspect and manipulate NGWAF Slack alerts. +package slack diff --git a/pkg/commands/ngwaf/workspace/alert/slack/get.go b/pkg/commands/ngwaf/workspace/alert/slack/get.go new file mode 100644 index 000000000..a903d0c52 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/get.go @@ -0,0 +1,86 @@ +package slack + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" +) + +// GetCommand calls the Fastly API to get Slack alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Slack alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &slack.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := slack.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/slack/list.go b/pkg/commands/ngwaf/workspace/alert/slack/list.go new file mode 100644 index 000000000..d3ce71f11 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/list.go @@ -0,0 +1,78 @@ +package slack + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" +) + +// ListCommand calls the Fastly API to list Slack alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Slack alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &slack.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := slack.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/slack/root.go b/pkg/commands/ngwaf/workspace/alert/slack/root.go new file mode 100644 index 000000000..e8253a1e4 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/root.go @@ -0,0 +1,31 @@ +package slack + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "slack" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Slack workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/alert/slack/slack_test.go b/pkg/commands/ngwaf/workspace/alert/slack/slack_test.go new file mode 100644 index 000000000..85e3160a7 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/slack_test.go @@ -0,0 +1,413 @@ +package slack_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/slack" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" +) + +const ( + alertID = "1a2b3c4d5e6f7890abcdef12" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestSlackAlert" +) + +var ( + webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX" + slackAlert = slack.Alert{ + ID: alertID, + Type: "slack", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: slack.ResponseConfig{ + Webhook: &webhook, + }, + } +) + +func TestSlackAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--webhook %s", webhook), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --webhook flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --webhook not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", slackAlert.Type, slackAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --webhook %s --json", workspaceID, webhook), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(slackAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestSlackAlertList(t *testing.T) { + alertsObject := slack.Alerts{ + Data: []slack.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "slack", + Description: "First slack alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: slack.ResponseConfig{ + Webhook: &webhook, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "slack", + Description: "Second slack alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: slack.ResponseConfig{ + Webhook: &webhook, + }, + }, + }, + Meta: slack.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(slack.Alerts{ + Data: []slack.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestSlackAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(slackAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestSlackAlertUpdate(t *testing.T) { + updatedWebhook := "https://hooks.slack.com/services/updated" + updatedAlert := slack.Alert{ + ID: alertID, + Type: "slack", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: slack.ResponseConfig{ + Webhook: &updatedWebhook, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --webhook %s", alertID, webhook), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --webhook https://hooks.slack.com/services/updated", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with webhook", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://hooks.slack.com/services/updated", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(slackAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://hooks.slack.com/services/updated --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(slackAlert))), + }, + + { + StatusCode: http.StatusOK, + + Status: http.StatusText(http.StatusOK), + + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestSlackAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 1a2b3c4d5e6f7890abcdef12 +Type: slack +Description: TestSlackAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Webhook: +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 slack First slack alert 2025-11-25T16:40:12Z test@example.com Webhook: +2b3c4d5e6f7890abcdef1234 slack Second slack alert 2025-11-25T16:40:12Z test@example.com Webhook: +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/alert/slack/update.go b/pkg/commands/ngwaf/workspace/alert/slack/update.go new file mode 100644 index 000000000..6e4a8bc47 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/slack/update.go @@ -0,0 +1,96 @@ +package slack + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" +) + +// UpdateCommand calls the Fastly API to update Slack alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Webhook string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Slack alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("webhook", "Slack webhook.").Required().StringVar(&c.Webhook) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &slack.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &slack.UpdateConfig{ + Webhook: &c.Webhook, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := slack.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/create.go b/pkg/commands/ngwaf/workspace/alert/webhook/create.go new file mode 100644 index 000000000..4fa30ed07 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/create.go @@ -0,0 +1,95 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// CreateCommand calls the Fastly API to create Webhook alerts. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID + Webhook string + + // Optional. + Description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Webhook alert").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.CmdClause.Flag("webhook", "Webhook webhook.").Required().StringVar(&c.Webhook) + + // Optional. + c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &webhook.CreateInput{ + WorkspaceID: &c.WorkspaceID.Value, + Config: &webhook.CreateConfig{ + Webhook: &c.Webhook, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + if c.Description.WasSet { + input.Description = &c.Description.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := webhook.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/delete.go b/pkg/commands/ngwaf/workspace/alert/webhook/delete.go new file mode 100644 index 000000000..8b175aba3 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/delete.go @@ -0,0 +1,92 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// DeleteCommand calls the Fastly API to delete Webhook alerts. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Webhook alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := webhook.Delete(context.TODO(), fc, &webhook.DeleteInput{ + WorkspaceID: &c.WorkspaceID.Value, + AlertID: &c.AlertID, + }) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.AlertID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/doc.go b/pkg/commands/ngwaf/workspace/alert/webhook/doc.go new file mode 100644 index 000000000..09586faed --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/doc.go @@ -0,0 +1,2 @@ +// Package webhook contains commands to inspect and manipulate NGWAF Webhook alerts. +package webhook diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/get-signing-key.go b/pkg/commands/ngwaf/workspace/alert/webhook/get-signing-key.go new file mode 100644 index 000000000..75493c6e8 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/get-signing-key.go @@ -0,0 +1,86 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// GetSigningKeyCommand calls the Fastly API to get Webhook alerts. +type GetSigningKeyCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetSigningKeyCommand returns a usable command registered under the parent. +func NewGetSigningKeyCommand(parent argparser.Registerer, g *global.Data) *GetSigningKeyCommand { + c := GetSigningKeyCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get-signing-key", "Retrieves details of a webhook alert signing key") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetSigningKeyCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &webhook.GetKeyInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := webhook.GetKey(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Signing Key: '%s' (Workspace: %s)", data.SigningKey, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/get.go b/pkg/commands/ngwaf/workspace/alert/webhook/get.go new file mode 100644 index 000000000..9c225395d --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/get.go @@ -0,0 +1,86 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// GetCommand calls the Fastly API to get Webhook alerts. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Get a Webhook alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &webhook.GetInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := webhook.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlert(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/list.go b/pkg/commands/ngwaf/workspace/alert/webhook/list.go new file mode 100644 index 000000000..6371fc1e2 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/list.go @@ -0,0 +1,78 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// ListCommand calls the Fastly API to list Webhook alerts. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Webhook alerts") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &webhook.ListInput{ + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := webhook.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintAlertTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/root.go b/pkg/commands/ngwaf/workspace/alert/webhook/root.go new file mode 100644 index 000000000..a6dcccadb --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/root.go @@ -0,0 +1,31 @@ +package webhook + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "webhook" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Webhook workspace alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/rotate-signing-key.go b/pkg/commands/ngwaf/workspace/alert/webhook/rotate-signing-key.go new file mode 100644 index 000000000..68fe2d00d --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/rotate-signing-key.go @@ -0,0 +1,86 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// RotateSigningKeyCommand calls the Fastly API to get Webhook alerts. +type RotateSigningKeyCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID +} + +// NewRotateSigningKeyCommand returns a usable command registered under the parent. +func NewRotateSigningKeyCommand(parent argparser.Registerer, g *global.Data) *RotateSigningKeyCommand { + c := RotateSigningKeyCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("rotate-signing-key", "Rotate webhook alert signing key") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *RotateSigningKeyCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + input := &webhook.RotateKeyInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := webhook.RotateKey(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Signing Key: '%s' (Workspace: %s)", data.SigningKey, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/update.go b/pkg/commands/ngwaf/workspace/alert/webhook/update.go new file mode 100644 index 000000000..544417df2 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/update.go @@ -0,0 +1,96 @@ +package webhook + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// UpdateCommand calls the Fastly API to update Webhook alerts. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + AlertID string + WorkspaceID argparser.OptionalWorkspaceID + Webhook string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Webhook alert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.WorkspaceID.Value, + Action: c.WorkspaceID.Set, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFAlertID, + Description: argparser.FlagNGWAFAlertIDDesc, + Dst: &c.AlertID, + Required: true, + }) + c.CmdClause.Flag("webhook", "Webhook webhook.").Required().StringVar(&c.Webhook) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.WorkspaceID.Parse(); err != nil { + return err + } + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &webhook.UpdateInput{ + AlertID: &c.AlertID, + WorkspaceID: &c.WorkspaceID.Value, + Config: &webhook.UpdateConfig{ + Webhook: &c.Webhook, + }, + // Set 'Events' to the only possible value, 'flag' + Events: alert.GetDefaultEvents(), + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := webhook.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/alert/webhook/webhook_test.go b/pkg/commands/ngwaf/workspace/alert/webhook/webhook_test.go new file mode 100644 index 000000000..f5030f9be --- /dev/null +++ b/pkg/commands/ngwaf/workspace/alert/webhook/webhook_test.go @@ -0,0 +1,541 @@ +package webhook_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/webhook" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +const ( + alertID = "6f7890abcdef123456789012" + workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" + description = "TestWebhookAlert" + signingKey = "a1b2c3d4e5f67890abcdef1234567890" +) + +var ( + webhookURL = "https://example.com/webhook" + webhookAlert = webhook.Alert{ + ID: alertID, + Type: "webhook", + Description: description, + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: webhook.ResponseConfig{ + Webhook: &webhookURL, + }, + } +) + +func TestWebhookAlertCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--webhook %s", webhookURL), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --webhook flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --webhook not provided", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhookURL), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", webhookAlert.Type, webhookAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --webhook %s --json", workspaceID, webhookURL), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(webhookAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestWebhookAlertList(t *testing.T) { + alertsObject := webhook.Alerts{ + Data: []webhook.Alert{ + { + ID: "1a2b3c4d5e6f7890abcdef12", + Type: "webhook", + Description: "First webhook alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: webhook.ResponseConfig{ + Webhook: &webhookURL, + }, + }, + { + ID: "2b3c4d5e6f7890abcdef1234", + Type: "webhook", + Description: "Second webhook alert", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: webhook.ResponseConfig{ + Webhook: &webhookURL, + }, + }, + }, + Meta: webhook.MetaAlerts{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero alerts)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(webhook.Alerts{ + Data: []webhook.Alert{}, + }))), + }, + }, + }, + WantOutput: zeroListString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: listString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(alertsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestWebhookAlertGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), + }, + }, + }, + WantOutput: alertString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(webhookAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestWebhookAlertUpdate(t *testing.T) { + updatedWebhookURL := "https://example.com/webhook/updated" + updatedAlert := webhook.Alert{ + ID: alertID, + Type: "webhook", + Description: "Updated description", + CreatedAt: "2025-11-25T16:40:12Z", + CreatedBy: "test@example.com", + Config: webhook.ResponseConfig{ + Webhook: &updatedWebhookURL, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s --webhook %s", alertID, webhookURL), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhookURL), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --webhook https://example.com/webhook/updated", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success with webhook", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://example.com/webhook/updated", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(webhookAlert))), + }, + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://example.com/webhook/updated --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MultiResponseRoundTripper{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(webhookAlert))), + }, + { + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), + }, + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(updatedAlert), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestWebhookAlertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestWebhookGetSigningKey(t *testing.T) { + signingKeyResponse := webhook.AlertsKey{ + SigningKey: signingKey, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), + }, + }, + }, + WantOutput: fstfmt.Success("Signing Key: '%s' (Workspace: %s)", signingKeyResponse.SigningKey, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(signingKeyResponse), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get-signing-key"}, scenarios) +} + +func TestWebhookRotateSigningKey(t *testing.T) { + newSigningKey := "new-signing-key-0987654321" + signingKeyResponse := webhook.AlertsKey{ + SigningKey: newSigningKey, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--alert-id %s", alertID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate missing --alert-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --alert-id not provided", + }, + { + Name: "validate not found", + Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "This resource does not exist", + "status": 404 + } + `))), + }, + }, + }, + WantError: "404 - Not Found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), + }, + }, + }, + WantOutput: fstfmt.Success("Signing Key: '%s' (Workspace: %s)", signingKeyResponse.SigningKey, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(signingKeyResponse), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "rotate-signing-key"}, scenarios) +} + +var alertString = strings.TrimSpace(` +ID: 6f7890abcdef123456789012 +Type: webhook +Description: TestWebhookAlert +Created At: 2025-11-25T16:40:12Z +Created By: test@example.com +Config: + Webhook: +`) + +var listString = strings.TrimSpace(` +ID Type Description Created At Created By Config +1a2b3c4d5e6f7890abcdef12 webhook First webhook alert 2025-11-25T16:40:12Z test@example.com Webhook: +2b3c4d5e6f7890abcdef1234 webhook Second webhook alert 2025-11-25T16:40:12Z test@example.com Webhook: +`) + "\n" + +var zeroListString = strings.TrimSpace(` +ID Type Description Created At Created By Config +`) + "\n" diff --git a/pkg/testutil/client.go b/pkg/testutil/client.go index 53899df69..213645a62 100644 --- a/pkg/testutil/client.go +++ b/pkg/testutil/client.go @@ -13,3 +13,25 @@ type MockRoundTripper struct { func (m *MockRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { return m.Response, m.Err } + +// MultiResponseRoundTripper implements [http.RoundTripper] for mocking multiple +// sequential HTTP responses. This is useful when the code under test makes +// multiple HTTP calls (e.g., GET then PATCH). +// +// When we perform a get and update in go-fastly operations (such as for alerts), +// we need to be able to parse multiple responses back from the API. +type MultiResponseRoundTripper struct { + Responses []*http.Response + index int +} + +// RoundTrip executes a single HTTP transaction, returning the next Response +// in sequence. +func (m *MultiResponseRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { + if m.index >= len(m.Responses) { + return m.Responses[len(m.Responses)-1], nil + } + resp := m.Responses[m.index] + m.index++ + return resp, nil +} diff --git a/pkg/text/alerts.go b/pkg/text/alerts.go new file mode 100644 index 000000000..661cced87 --- /dev/null +++ b/pkg/text/alerts.go @@ -0,0 +1,288 @@ +package text + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/datadog" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/jira" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/mailinglist" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/opsgenie" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/pagerduty" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/slack" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/alerts/webhook" +) + +// PrintAlert displays a single alert. +// Accepts any alert type (datadog, slack, webhook, etc.) via any. +func PrintAlert(out io.Writer, alert any) { + var id, alertType, description, createdAt, createdBy string + var config any + + // Extract common fields based on type + switch a := alert.(type) { + case *datadog.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *jira.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *mailinglist.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *microsoftteams.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *opsgenie.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *pagerduty.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *slack.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + case *webhook.Alert: + id = a.ID + alertType = a.Type + description = a.Description + createdAt = a.CreatedAt + createdBy = a.CreatedBy + config = a.Config + default: + fmt.Fprintf(out, "Unknown alert type\n") + return + } + + fmt.Fprintf(out, "ID: %s\n", id) + fmt.Fprintf(out, "Type: %s\n", alertType) + fmt.Fprintf(out, "Description: %s\n", description) + fmt.Fprintf(out, "Created At: %s\n", createdAt) + fmt.Fprintf(out, "Created By: %s\n", createdBy) + printAlertConfig(out, alertType, config) +} + +// printAlertConfig prints alert configuration based on type. +func printAlertConfig(out io.Writer, alertType string, config any) { + fmt.Fprint(out, "Config:\n") + switch alertType { + case "datadog": + printDatadogConfig(out, config) + case "jira": + printJiraConfig(out, config) + case "mailinglist": + printMailingListConfig(out, config) + case "microsoftteams", "slack", "webhook": + printWebhookConfig(out, config) + case "opsgenie", "pagerduty": + printKeyConfig(out, config) + default: + fmt.Fprintf(out, " (unknown type: %s)\n", alertType) + } +} + +// printDatadogConfig prints Datadog-specific configuration. +func printDatadogConfig(out io.Writer, config any) { + if cfg, ok := config.(datadog.ResponseConfig); ok { + if cfg.Key != nil { + fmt.Fprintf(out, " Key: \n") + } + if cfg.Site != nil { + fmt.Fprintf(out, " Site: %s\n", *cfg.Site) + } + } +} + +// printWebhookConfig prints webhook-based configuration (slack, webhook, microsoftteams). +func printWebhookConfig(out io.Writer, config any) { + var hasWebhook bool + + switch cfg := config.(type) { + case slack.ResponseConfig: + hasWebhook = cfg.Webhook != nil + case webhook.ResponseConfig: + hasWebhook = cfg.Webhook != nil + case microsoftteams.ResponseConfig: + hasWebhook = cfg.Webhook != nil + } + + if hasWebhook { + fmt.Fprintf(out, " Webhook: \n") + } +} + +// printJiraConfig prints Jira-specific configuration. +func printJiraConfig(out io.Writer, config any) { + if cfg, ok := config.(jira.ResponseConfig); ok { + if cfg.Host != nil { + fmt.Fprintf(out, " Host: %s\n", *cfg.Host) + } + if cfg.Username != nil { + fmt.Fprintf(out, " Username: %s\n", *cfg.Username) + } + if cfg.Project != nil { + fmt.Fprintf(out, " Project: %s\n", *cfg.Project) + } + if cfg.IssueType != nil { + fmt.Fprintf(out, " Issue Type: %s\n", *cfg.IssueType) + } + if cfg.Key != nil { + fmt.Fprintf(out, " Key: \n") + } + } +} + +// printMailingListConfig prints Mailing List-specific configuration. +func printMailingListConfig(out io.Writer, config any) { + if cfg, ok := config.(mailinglist.ResponseConfig); ok { + if cfg.Address != nil { + fmt.Fprintf(out, " Address: %s\n", *cfg.Address) + } + } +} + +// printKeyConfig prints key-based configuration (opsgenie, pagerduty). +func printKeyConfig(out io.Writer, config any) { + var hasKey bool + + switch cfg := config.(type) { + case opsgenie.ResponseConfig: + hasKey = cfg.Key != nil + case pagerduty.ResponseConfig: + hasKey = cfg.Key != nil + } + + if hasKey { + fmt.Fprintf(out, " Key: \n") + } +} + +// getConfigSummary returns a string summary of the alert config with sensitive fields redacted. +func getConfigSummary(alertType string, config any) string { + switch alertType { + case "datadog": + if cfg, ok := config.(datadog.ResponseConfig); ok { + parts := []string{} + if cfg.Site != nil { + parts = append(parts, fmt.Sprintf("Site: %s", *cfg.Site)) + } + if cfg.Key != nil { + parts = append(parts, "Key: ") + } + return strings.Join(parts, ", ") + } + case "jira": + if cfg, ok := config.(jira.ResponseConfig); ok { + parts := []string{} + if cfg.Host != nil { + parts = append(parts, fmt.Sprintf("Host: %s", *cfg.Host)) + } + if cfg.IssueType != nil { + parts = append(parts, fmt.Sprintf("Issue Type: %s", *cfg.IssueType)) + } + if cfg.Key != nil { + parts = append(parts, "Key: ") + } + if cfg.Project != nil { + parts = append(parts, fmt.Sprintf("Project: %s", *cfg.Project)) + } + if cfg.Username != nil { + parts = append(parts, fmt.Sprintf("Username: %s", *cfg.Username)) + } + return strings.Join(parts, ", ") + } + case "mailinglist": + if cfg, ok := config.(mailinglist.ResponseConfig); ok { + if cfg.Address != nil { + return fmt.Sprintf("Address: %s", *cfg.Address) + } + } + case "microsoftteams", "slack", "webhook": + return "Webhook: " + case "opsgenie", "pagerduty": + return "Key: " + } + return "" +} + +// PrintAlertTbl prints a table of alerts. +func PrintAlertTbl(out io.Writer, alerts any) { + tbl := NewTable(out) + tbl.AddHeader("ID", "Type", "Description", "Created At", "Created By", "Config") + + // Handle different alert type slices + switch a := alerts.(type) { + case []datadog.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []slack.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []webhook.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []jira.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []mailinglist.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []microsoftteams.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []opsgenie.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + case []pagerduty.Alert: + for _, alert := range a { + configSummary := getConfigSummary(alert.Type, alert.Config) + tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) + } + } + + tbl.Print() +}