From 7844dd8dee4d3835dbc2d1456e7a1d70e01f73f9 Mon Sep 17 00:00:00 2001 From: Mike Cobbett <77053+techcobweb@users.noreply.github.com> Date: Wed, 30 Oct 2024 10:22:00 +0000 Subject: [PATCH 1/4] generated code changed. Unit test needs to change also Signed-off-by: Mike Cobbett <77053+techcobweb@users.noreply.github.com> --- pkg/users/usersGet_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/users/usersGet_test.go b/pkg/users/usersGet_test.go index 314f4cea..6a6e312e 100644 --- a/pkg/users/usersGet_test.go +++ b/pkg/users/usersGet_test.go @@ -79,7 +79,7 @@ func TestMeInputLoginIdPrintsDetailsOnConsole(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") - jsonToReturn := `[{ "login_id": "myUserId" }]` + jsonToReturn := `[{ "login-id": "myUserId" }]` writer.Write([]byte(jsonToReturn)) })) From 6e7fe65f292384f55bb7fdb7eebb33231a60dc5e Mon Sep 17 00:00:00 2001 From: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:47:38 +0000 Subject: [PATCH 2/4] Add galasactl secrets delete command (#300) * feat: Add galasactl secrets delete command Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * feat: Add validation to secrets Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * Empty commit to kick off build Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --------- Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --- .secrets.baseline | 20 ++ README.md | 12 + docs/generated/errors-list.md | 7 + docs/generated/galasactl.md | 1 + docs/generated/galasactl_secrets.md | 27 ++ docs/generated/galasactl_secrets_delete.md | 31 +++ pkg/cmd/commandCollection.go | 26 ++ pkg/cmd/secrets.go | 95 +++++++ pkg/cmd/secretsDelete.go | 140 +++++++++++ pkg/cmd/secretsDelete_test.go | 58 +++++ pkg/cmd/secrets_test.go | 59 +++++ pkg/errors/errorMessage.go | 12 +- pkg/errors/galasaAPIError.go | 52 ++++ pkg/runs/runsDelete.go | 54 +--- pkg/secrets/secrets.go | 36 +++ pkg/secrets/secretsDelete.go | 77 ++++++ pkg/secrets/secretsDelete_test.go | 274 +++++++++++++++++++++ 17 files changed, 930 insertions(+), 51 deletions(-) create mode 100644 docs/generated/galasactl_secrets.md create mode 100644 docs/generated/galasactl_secrets_delete.md create mode 100644 pkg/cmd/secrets.go create mode 100644 pkg/cmd/secretsDelete.go create mode 100644 pkg/cmd/secretsDelete_test.go create mode 100644 pkg/cmd/secrets_test.go create mode 100644 pkg/secrets/secrets.go create mode 100644 pkg/secrets/secretsDelete.go create mode 100644 pkg/secrets/secretsDelete_test.go diff --git a/.secrets.baseline b/.secrets.baseline index c469e8b5..99a6d52b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -76,6 +76,16 @@ } ], "results": { + "dockerfiles/certs/ibminter.pem": [ + { + "hashed_secret": "b3723414cb4a90ac8c2bc504ea01923fe5fccc8a", + "is_secret": false, + "is_verified": false, + "line_number": 28, + "type": "Artifactory Credentials", + "verified_result": null + } + ], "gherkin-docs.md": [ { "hashed_secret": "c61db10457a740b07845146f2d1b391c133a6ebf", @@ -125,6 +135,16 @@ "type": "Hex High Entropy String", "verified_result": null } + ], + "pkg/secrets/secretsDelete_test.go": [ + { + "hashed_secret": "2dfbe3ec00a96d6f711d9a70f78be17f6fd574ca", + "is_secret": false, + "is_verified": false, + "line_number": 83, + "type": "Secret Keyword", + "verified_result": null + } ] }, "version": "0.13.1+ibm.62.dss", diff --git a/README.md b/README.md index 7bf1641a..8ff0f92c 100644 --- a/README.md +++ b/README.md @@ -632,7 +632,19 @@ galasactl resources delete -f my_resources.yaml For a complete list of supported parameters see [here](./docs/generated/galasactl_resources_delete.md). +## secrets delete +This command deletes a secret with the given name from the Galasa Ecosystem's credentials store. The name of the secret to be deleted must be provided using the `--name` flag. + +### Examples + +To delete a secret named `SYSTEM1`, run the following command: + +``` +galasactl secrets delete --name SYSTEM1 +``` + +For a complete list of supported parameters see [here](./docs/generated/galasactl_secrets_delete.md). ## Reference Material diff --git a/docs/generated/errors-list.md b/docs/generated/errors-list.md index d3ccc523..f535bcc8 100644 --- a/docs/generated/errors-list.md +++ b/docs/generated/errors-list.md @@ -162,6 +162,13 @@ The `galasactl` tool can generate the following errors: - GAL1164E: An attempt to delete a run named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in the json format. - GAL1165E: '{}' is not supported as a valid login ID. Login ID should not contain spaces. - GAL1166E: The loginId provided by the --user field cannot be an empty string. +- GAL1167E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. +- GAL1168E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server could not be read. Cause: {} +- GAL1169E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in a valid json format. Cause: '{}' +- GAL1170E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are: '{}' +- GAL1171E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in the json format. +- GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty or contain spaces, and must only contain characters in the Latin-1 character set. +- GAL1173E: An attempt to delete a secret named '{}' failed. Sending the delete request to the Galasa service failed. Cause is {} - GAL1225E: Failed to open file '{}' cause: {}. Check that this file exists, and that you have read permissions. - GAL1226E: Internal failure. Contents of gzip could be read, but not decoded. New gzip reader failed: file: {} error: {} - GAL1227E: Internal failure. Contents of gzip could not be decoded. {} error: {} diff --git a/docs/generated/galasactl.md b/docs/generated/galasactl.md index fab02d62..9729626d 100644 --- a/docs/generated/galasactl.md +++ b/docs/generated/galasactl.md @@ -22,5 +22,6 @@ A tool for controlling Galasa resources using the command-line. * [galasactl properties](galasactl_properties.md) - Manages properties in an ecosystem * [galasactl resources](galasactl_resources.md) - Manages resources in an ecosystem * [galasactl runs](galasactl_runs.md) - Manage test runs in the ecosystem +* [galasactl secrets](galasactl_secrets.md) - Manage secrets stored in the Galasa service's credentials store * [galasactl users](galasactl_users.md) - Manages users in an ecosystem diff --git a/docs/generated/galasactl_secrets.md b/docs/generated/galasactl_secrets.md new file mode 100644 index 00000000..48ac5e1b --- /dev/null +++ b/docs/generated/galasactl_secrets.md @@ -0,0 +1,27 @@ +## galasactl secrets + +Manage secrets stored in the Galasa service's credentials store + +### Synopsis + +The parent command for operations to manipulate secrets in the Galasa service's credentials store + +### Options + +``` + -b, --bootstrap string Bootstrap URL. Should start with 'http://' or 'file://'. If it starts with neither, it is assumed to be a fully-qualified path. If missing, it defaults to use the 'bootstrap.properties' file in your GALASA_HOME. Example: http://example.com/bootstrap, file:///user/myuserid/.galasa/bootstrap.properties , file://C:/Users/myuserid/.galasa/bootstrap.properties + -h, --help Displays the options for the 'secrets' command. +``` + +### Options inherited from parent commands + +``` + --galasahome string Path to a folder where Galasa will read and write files and configuration settings. The default is '${HOME}/.galasa'. This overrides the GALASA_HOME environment variable which may be set instead. + -l, --log string File to which log information will be sent. Any folder referred to must exist. An existing file will be overwritten. Specify "-" to log to stderr. Defaults to not logging. +``` + +### SEE ALSO + +* [galasactl](galasactl.md) - CLI for Galasa +* [galasactl secrets delete](galasactl_secrets_delete.md) - Deletes a secret from the credentials store + diff --git a/docs/generated/galasactl_secrets_delete.md b/docs/generated/galasactl_secrets_delete.md new file mode 100644 index 00000000..a48273aa --- /dev/null +++ b/docs/generated/galasactl_secrets_delete.md @@ -0,0 +1,31 @@ +## galasactl secrets delete + +Deletes a secret from the credentials store + +### Synopsis + +Deletes a secret from the credentials store + +``` +galasactl secrets delete [flags] +``` + +### Options + +``` + -h, --help Displays the options for the 'secrets delete' command. + --name string A mandatory flag that identifies the secret to be created or manipulated. +``` + +### Options inherited from parent commands + +``` + -b, --bootstrap string Bootstrap URL. Should start with 'http://' or 'file://'. If it starts with neither, it is assumed to be a fully-qualified path. If missing, it defaults to use the 'bootstrap.properties' file in your GALASA_HOME. Example: http://example.com/bootstrap, file:///user/myuserid/.galasa/bootstrap.properties , file://C:/Users/myuserid/.galasa/bootstrap.properties + --galasahome string Path to a folder where Galasa will read and write files and configuration settings. The default is '${HOME}/.galasa'. This overrides the GALASA_HOME environment variable which may be set instead. + -l, --log string File to which log information will be sent. Any folder referred to must exist. An existing file will be overwritten. Specify "-" to log to stderr. Defaults to not logging. +``` + +### SEE ALSO + +* [galasactl secrets](galasactl_secrets.md) - Manage secrets stored in the Galasa service's credentials store + diff --git a/pkg/cmd/commandCollection.go b/pkg/cmd/commandCollection.go index dec89295..8ae99d8f 100644 --- a/pkg/cmd/commandCollection.go +++ b/pkg/cmd/commandCollection.go @@ -59,6 +59,8 @@ const ( COMMAND_NAME_RESOURCES_CREATE = "resources create" COMMAND_NAME_RESOURCES_UPDATE = "resources update" COMMAND_NAME_RESOURCES_DELETE = "resources delete" + COMMAND_NAME_SECRETS = "secrets" + COMMAND_NAME_SECRETS_DELETE = "secrets delete" COMMAND_NAME_USERS = "users" COMMAND_NAME_USERS_GET = "users get" ) @@ -142,6 +144,10 @@ func (commands *commandCollectionImpl) init(factory spi.Factory) error { err = commands.addResourcesCommands(factory, rootCommand) } + if err == nil { + err = commands.addSecretsCommands(factory, rootCommand) + } + if err == nil { err = commands.addUsersCommands(factory, rootCommand) } @@ -375,6 +381,26 @@ func (commands *commandCollectionImpl) addResourcesCommands(factory spi.Factory, return err } +func (commands *commandCollectionImpl) addSecretsCommands(factory spi.Factory, rootCommand spi.GalasaCommand) error { + + var err error + var secretsCommand spi.GalasaCommand + var secretsDeleteCommand spi.GalasaCommand + + secretsCommand, err = NewSecretsCmd(rootCommand) + + if err == nil { + secretsDeleteCommand, err = NewSecretsDeleteCommand(factory, secretsCommand, rootCommand) + } + + if err == nil { + commands.commandMap[secretsCommand.Name()] = secretsCommand + commands.commandMap[secretsDeleteCommand.Name()] = secretsDeleteCommand + } + + return err +} + func (commands *commandCollectionImpl) addUsersCommands(factory spi.Factory, rootCommand spi.GalasaCommand) error { var err error diff --git a/pkg/cmd/secrets.go b/pkg/cmd/secrets.go new file mode 100644 index 00000000..54683e98 --- /dev/null +++ b/pkg/cmd/secrets.go @@ -0,0 +1,95 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "github.com/galasa-dev/cli/pkg/spi" + "github.com/spf13/cobra" +) + +type SecretsCmdValues struct { + bootstrap string + name string +} + +type SecretsCommand struct { + cobraCommand *cobra.Command + values *SecretsCmdValues +} + +// ------------------------------------------------------------------------------------------------ +// Constructors +// ------------------------------------------------------------------------------------------------ + +func NewSecretsCmd(rootCommand spi.GalasaCommand) (spi.GalasaCommand, error) { + cmd := new(SecretsCommand) + err := cmd.init(rootCommand) + return cmd, err +} + +// ------------------------------------------------------------------------------------------------ +// Public functions +// ------------------------------------------------------------------------------------------------ + +func (cmd *SecretsCommand) Name() string { + return COMMAND_NAME_SECRETS +} + +func (cmd *SecretsCommand) CobraCommand() *cobra.Command { + return cmd.cobraCommand +} + +func (cmd *SecretsCommand) Values() interface{} { + return cmd.values +} + +// ------------------------------------------------------------------------------------------------ +// Private functions +// ------------------------------------------------------------------------------------------------ + +func (cmd *SecretsCommand) init(rootCmd spi.GalasaCommand) error { + + var err error + + cmd.values = &SecretsCmdValues{} + cmd.cobraCommand, err = cmd.createCobraCommand(rootCmd) + + return err +} + +func (cmd *SecretsCommand) createCobraCommand(rootCommand spi.GalasaCommand) (*cobra.Command, error) { + + var err error + + secretsCobraCmd := &cobra.Command{ + Use: "secrets", + Short: "Manage secrets stored in the Galasa service's credentials store", + Long: "The parent command for operations to manipulate secrets in the Galasa service's credentials store", + } + + addBootstrapFlag(secretsCobraCmd, &cmd.values.bootstrap) + + rootCommand.CobraCommand().AddCommand(secretsCobraCmd) + + return secretsCobraCmd, err +} + +func addSecretNameFlag(cmd *cobra.Command, isMandatory bool, secretsCmdValues *SecretsCmdValues) { + + flagName := "name" + var description string + if isMandatory { + description = "A mandatory flag that identifies the secret to be created or manipulated." + } else { + description = "An optional flag that identifies the secret to be retrieved." + } + + cmd.Flags().StringVar(&secretsCmdValues.name, flagName, "", description) + + if isMandatory { + cmd.MarkFlagRequired(flagName) + } +} diff --git a/pkg/cmd/secretsDelete.go b/pkg/cmd/secretsDelete.go new file mode 100644 index 00000000..bcc361bb --- /dev/null +++ b/pkg/cmd/secretsDelete.go @@ -0,0 +1,140 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "log" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/secrets" + "github.com/galasa-dev/cli/pkg/spi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/spf13/cobra" +) + +type SecretsDeleteCommand struct { + cobraCommand *cobra.Command +} + +// ------------------------------------------------------------------------------------------------ +// Constructors methods +// ------------------------------------------------------------------------------------------------ +func NewSecretsDeleteCommand( + factory spi.Factory, + secretsDeleteCommand spi.GalasaCommand, + rootCmd spi.GalasaCommand, +) (spi.GalasaCommand, error) { + + cmd := new(SecretsDeleteCommand) + + err := cmd.init(factory, secretsDeleteCommand, rootCmd) + return cmd, err +} + +// ------------------------------------------------------------------------------------------------ +// Public methods +// ------------------------------------------------------------------------------------------------ +func (cmd *SecretsDeleteCommand) Name() string { + return COMMAND_NAME_SECRETS_DELETE +} + +func (cmd *SecretsDeleteCommand) CobraCommand() *cobra.Command { + return cmd.cobraCommand +} + +func (cmd *SecretsDeleteCommand) Values() interface{} { + return nil +} + +// ------------------------------------------------------------------------------------------------ +// Private methods +// ------------------------------------------------------------------------------------------------ +func (cmd *SecretsDeleteCommand) init(factory spi.Factory, secretsCommand spi.GalasaCommand, rootCmd spi.GalasaCommand) error { + var err error + + cmd.cobraCommand, err = cmd.createCobraCmd(factory, secretsCommand, rootCmd.Values().(*RootCmdValues)) + + return err +} + +func (cmd *SecretsDeleteCommand) createCobraCmd( + factory spi.Factory, + secretsCommand spi.GalasaCommand, + rootCommandValues *RootCmdValues, +) (*cobra.Command, error) { + + var err error + + secretsCommandValues := secretsCommand.Values().(*SecretsCmdValues) + secretsDeleteCobraCmd := &cobra.Command{ + Use: "delete", + Short: "Deletes a secret from the credentials store", + Long: "Deletes a secret from the credentials store", + Aliases: []string{COMMAND_NAME_SECRETS_DELETE}, + RunE: func(cobraCommand *cobra.Command, args []string) error { + return cmd.executeSecretsDelete(factory, secretsCommand.Values().(*SecretsCmdValues), rootCommandValues) + }, + } + + addSecretNameFlag(secretsDeleteCobraCmd, true, secretsCommandValues) + + secretsCommand.CobraCommand().AddCommand(secretsDeleteCobraCmd) + + return secretsDeleteCobraCmd, err +} + +func (cmd *SecretsDeleteCommand) executeSecretsDelete( + factory spi.Factory, + secretsCmdValues *SecretsCmdValues, + rootCmdValues *RootCmdValues, +) error { + + var err error + // Operations on the file system will all be relative to the current folder. + fileSystem := factory.GetFileSystem() + + err = utils.CaptureLog(fileSystem, rootCmdValues.logFileName) + + if err == nil { + rootCmdValues.isCapturingLogs = true + + log.Println("Galasa CLI - Delete a secret from the credentials store") + + env := factory.GetEnvironment() + + var galasaHome spi.GalasaHome + galasaHome, err = utils.NewGalasaHome(fileSystem, env, rootCmdValues.CmdParamGalasaHomePath) + if err == nil { + + var urlService *api.RealUrlResolutionService = new(api.RealUrlResolutionService) + var bootstrapData *api.BootstrapData + bootstrapData, err = api.LoadBootstrap(galasaHome, fileSystem, env, secretsCmdValues.bootstrap, urlService) + if err == nil { + + var console = factory.GetStdOutConsole() + + apiServerUrl := bootstrapData.ApiServerURL + log.Printf("The API server is at '%s'\n", apiServerUrl) + + authenticator := factory.GetAuthenticator( + apiServerUrl, + galasaHome, + ) + + var apiClient *galasaapi.APIClient + apiClient, err = authenticator.GetAuthenticatedAPIClient() + + byteReader := factory.GetByteReader() + + if err == nil { + err = secrets.DeleteSecret(secretsCmdValues.name, console, apiClient, byteReader) + } + } + } + } + return err +} diff --git a/pkg/cmd/secretsDelete_test.go b/pkg/cmd/secretsDelete_test.go new file mode 100644 index 00000000..1c8fe7d7 --- /dev/null +++ b/pkg/cmd/secretsDelete_test.go @@ -0,0 +1,58 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "testing" + + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestCommandListContainsSecretsDeleteCommand(t *testing.T) { + /// Given... + factory := utils.NewMockFactory() + commands, _ := NewCommandCollection(factory) + + // When... + secretsCommand, err := commands.GetCommand(COMMAND_NAME_SECRETS_DELETE) + assert.Nil(t, err) + + // Then... + assert.NotNil(t, secretsCommand) + assert.Equal(t, COMMAND_NAME_SECRETS_DELETE, secretsCommand.Name()) + assert.Nil(t, secretsCommand.Values()) +} + +func TestSecretsDeleteHelpFlagSetCorrectly(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + + var args []string = []string{"secrets", "delete", "--help"} + + // When... + err := Execute(factory, args) + + // Then... + // Check what the user saw is reasonable. + checkOutput("Deletes a secret from the credentials store", "", factory, t) + + assert.Nil(t, err) +} + +func TestSecretsDeleteNoNameFlagProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "delete"} + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", "Error: required flag(s) \"name\" not set", factory, t) +} diff --git a/pkg/cmd/secrets_test.go b/pkg/cmd/secrets_test.go new file mode 100644 index 00000000..98921163 --- /dev/null +++ b/pkg/cmd/secrets_test.go @@ -0,0 +1,59 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "testing" + + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestCommandListContainsSecretsCommand(t *testing.T) { + /// Given... + factory := utils.NewMockFactory() + commands, _ := NewCommandCollection(factory) + + // When... + secretsCommand, err := commands.GetCommand(COMMAND_NAME_SECRETS) + assert.Nil(t, err) + + // Then... + assert.NotNil(t, secretsCommand) + assert.Equal(t, COMMAND_NAME_SECRETS, secretsCommand.Name()) + assert.NotNil(t, secretsCommand.Values()) + assert.IsType(t, &SecretsCmdValues{}, secretsCommand.Values()) +} + +func TestSecretsHelpFlagSetCorrectly(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + + var args []string = []string{"secrets", "--help"} + + // When... + err := Execute(factory, args) + + // Then... + // Check what the user saw is reasonable. + checkOutput("The parent command for operations to manipulate secrets in the Galasa service's credentials store", "", factory, t) + + assert.Nil(t, err) +} + +func TestSecretsNoCommandsProducesUsageReport(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets"} + + // When... + err := Execute(factory, args) + + // Then... + assert.Nil(t, err) + + checkOutput("Usage:\n galasactl secrets [command]", "", factory, t) +} diff --git a/pkg/errors/errorMessage.go b/pkg/errors/errorMessage.go index 598f58c9..decf13f1 100644 --- a/pkg/errors/errorMessage.go +++ b/pkg/errors/errorMessage.go @@ -260,8 +260,16 @@ var ( GALASA_ERROR_DELETE_RUNS_UNPARSEABLE_CONTENT = NewMessageType("GAL1161E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1161, STACK_TRACE_NOT_WANTED) GALASA_ERROR_DELETE_RUNS_SERVER_REPORTED_ERROR = NewMessageType("GAL1162E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1162, STACK_TRACE_NOT_WANTED) - GALASA_ERROR_SERVER_DELETE_RUN_NOT_FOUND = NewMessageType("GAL1163E: The run named '%s' could not be deleted because it was not found by the Galasa service. Try listing runs using 'galasactl runs get' to identify the one you wish to delete", 1163, STACK_TRACE_NOT_WANTED) - GALASA_ERROR_DELETE_RUNS_EXPLANATION_NOT_JSON = NewMessageType("GAL1164E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1164, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SERVER_DELETE_RUN_NOT_FOUND = NewMessageType("GAL1163E: The run named '%s' could not be deleted because it was not found by the Galasa service. Try listing runs using 'galasactl runs get' to identify the one you wish to delete", 1163, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_RUNS_EXPLANATION_NOT_JSON = NewMessageType("GAL1164E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1164, STACK_TRACE_NOT_WANTED) + + GALASA_ERROR_DELETE_SECRET_NO_RESPONSE_CONTENT = NewMessageType("GAL1167E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server.", 1167, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_SECRET_RESPONSE_BODY_UNREADABLE = NewMessageType("GAL1168E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server could not be read. Cause: %s", 1168, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_SECRET_UNPARSEABLE_CONTENT = NewMessageType("GAL1169E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1169, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_SECRET_SERVER_REPORTED_ERROR = NewMessageType("GAL1170E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1170, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_SECRET_EXPLANATION_NOT_JSON = NewMessageType("GAL1171E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1171, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_INVALID_SECRET_NAME = NewMessageType("GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty or contain spaces, and must only contain characters in the Latin-1 character set.", 1172, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_SECRET_REQUEST_FAILED = NewMessageType("GAL1173E: An attempt to delete a secret named '%s' failed. Sending the delete request to the Galasa service failed. Cause is %v", 1173, STACK_TRACE_NOT_WANTED) // Warnings... GALASA_WARNING_MAVEN_NO_GALASA_OBR_REPO = NewMessageType("GAL2000W: Warning: Maven configuration file settings.xml should contain a reference to a Galasa repository so that the galasa OBR can be resolved. The official release repository is '%s', and 'pre-release' repository is '%s'", 2000, STACK_TRACE_WANTED) diff --git a/pkg/errors/galasaAPIError.go b/pkg/errors/galasaAPIError.go index eb04bef8..bce7a074 100644 --- a/pkg/errors/galasaAPIError.go +++ b/pkg/errors/galasaAPIError.go @@ -9,6 +9,9 @@ package errors import ( "encoding/json" "log" + "net/http" + + "github.com/galasa-dev/cli/pkg/spi" ) type GalasaAPIError struct { @@ -41,3 +44,52 @@ func GetApiErrorFromResponseBytes(body []byte, marshallingErrorLambda func(marsh } return apiError, err } + +func HttpResponseToGalasaError( + response *http.Response, + identifier string, + byteReader spi.ByteReader, + errorMsgUnexpectedStatusCodeNoResponseBody *MessageType, + errorMsgUnableToReadResponseBody *MessageType, + errorMsgResponsePayloadInWrongFormat *MessageType, + errorMsgReceivedFromApiServer *MessageType, + errorMsgResponseContentTypeNotJson *MessageType, +) error { + defer response.Body.Close() + var err error + var responseBodyBytes []byte + statusCode := response.StatusCode + + if response.ContentLength == 0 { + log.Printf("Failed - HTTP response - status code: '%v'\n", statusCode) + err = NewGalasaError(errorMsgUnexpectedStatusCodeNoResponseBody, identifier, statusCode) + } else { + + contentType := response.Header.Get("Content-Type") + if contentType != "application/json" { + err = NewGalasaError(errorMsgResponseContentTypeNotJson, identifier, statusCode) + } else { + responseBodyBytes, err = byteReader.ReadAll(response.Body) + if err != nil { + err = NewGalasaError(errorMsgUnableToReadResponseBody, identifier, statusCode, err.Error()) + } else { + + var errorFromServer *GalasaAPIError + errorFromServer, err = GetApiErrorFromResponseBytes( + responseBodyBytes, + func (marshallingError error) error { + log.Printf("Failed - HTTP response - status code: '%v' payload in response is not json: '%v' \n", statusCode, string(responseBodyBytes)) + return NewGalasaError(errorMsgResponsePayloadInWrongFormat, identifier, statusCode, marshallingError) + }, + ) + + if err == nil { + // server returned galasa api error structure we understand. + log.Printf("Failed - HTTP response - status code: '%v' server responded with error message: '%v' \n", statusCode, errorMsgReceivedFromApiServer) + err = NewGalasaError(errorMsgReceivedFromApiServer, identifier, statusCode, errorFromServer.Message) + } + } + } + } + return err +} diff --git a/pkg/runs/runsDelete.go b/pkg/runs/runsDelete.go index 7aae4f46..1e25a312 100644 --- a/pkg/runs/runsDelete.go +++ b/pkg/runs/runsDelete.go @@ -84,6 +84,10 @@ func deleteRuns( apicall := apiClient.ResultArchiveStoreAPIApi.DeleteRasRunById(context, runId).ClientApiVersion(restApiVersion) httpResponse, err = apicall.Execute() + + if httpResponse != nil { + defer httpResponse.Body.Close() + } // 200-299 http status codes manifest in an error. if err != nil { @@ -91,7 +95,7 @@ func deleteRuns( // We never got a response, error sending it or something ? err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_SERVER_DELETE_RUNS_FAILED, err.Error()) } else { - err = httpResponseToGalasaError( + err = galasaErrors.HttpResponseToGalasaError( httpResponse, runName, byteReader, @@ -115,51 +119,3 @@ func deleteRuns( return err } -func httpResponseToGalasaError( - response *http.Response, - identifier string, - byteReader spi.ByteReader, - errorMsgUnexpectedStatusCodeNoResponseBody *galasaErrors.MessageType, - errorMsgUnableToReadResponseBody *galasaErrors.MessageType, - errorMsgResponsePayloadInWrongFormat *galasaErrors.MessageType, - errorMsgReceivedFromApiServer *galasaErrors.MessageType, - errorMsgResponseContentTypeNotJson *galasaErrors.MessageType, -) error { - defer response.Body.Close() - var err error - var responseBodyBytes []byte - statusCode := response.StatusCode - - if response.ContentLength == 0 { - log.Printf("Failed - HTTP response - status code: '%v'\n", statusCode) - err = galasaErrors.NewGalasaError(errorMsgUnexpectedStatusCodeNoResponseBody, identifier, statusCode) - } else { - - contentType := response.Header.Get("Content-Type") - if contentType != "application/json" { - err = galasaErrors.NewGalasaError(errorMsgResponseContentTypeNotJson, identifier, statusCode) - } else { - responseBodyBytes, err = byteReader.ReadAll(response.Body) - if err != nil { - err = galasaErrors.NewGalasaError(errorMsgUnableToReadResponseBody, identifier, statusCode, err.Error()) - } else { - - var errorFromServer *galasaErrors.GalasaAPIError - errorFromServer, err = galasaErrors.GetApiErrorFromResponseBytes( - responseBodyBytes, - func (marshallingError error) error { - log.Printf("Failed - HTTP response - status code: '%v' payload in response is not json: '%v' \n", statusCode, string(responseBodyBytes)) - return galasaErrors.NewGalasaError(errorMsgResponsePayloadInWrongFormat, identifier, statusCode, marshallingError) - }, - ) - - if err == nil { - // server returned galasa api error structure we understand. - log.Printf("Failed - HTTP response - status code: '%v' server responded with error message: '%v' \n", statusCode, errorMsgReceivedFromApiServer) - err = galasaErrors.NewGalasaError(errorMsgReceivedFromApiServer, identifier, statusCode, errorFromServer.Message) - } - } - } - } - return err -} diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go new file mode 100644 index 00000000..e73c5ebe --- /dev/null +++ b/pkg/secrets/secrets.go @@ -0,0 +1,36 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package secrets + +import ( + "strings" + + galasaErrors "github.com/galasa-dev/cli/pkg/errors" +) + +func validateSecretName(secretName string) (string, error) { + var err error + secretName = strings.TrimSpace(secretName) + + if secretName == "" || strings.ContainsAny(secretName, " \n\t") || !isLatin1(secretName) { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_INVALID_SECRET_NAME) + } + return secretName, err +} + +// Checks if a given string contains only characters in the Latin-1 character set (codepoints 0-255), +// returning true if so, and false otherwise +func isLatin1(str string) bool { + isValidLatin1 := true + for _, character := range str { + if character > 255 { + isValidLatin1 = false + break + } + } + return isValidLatin1 +} diff --git a/pkg/secrets/secretsDelete.go b/pkg/secrets/secretsDelete.go new file mode 100644 index 00000000..bf192ba4 --- /dev/null +++ b/pkg/secrets/secretsDelete.go @@ -0,0 +1,77 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package secrets + +import ( + "context" + "log" + "net/http" + + "github.com/galasa-dev/cli/pkg/embedded" + galasaErrors "github.com/galasa-dev/cli/pkg/errors" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/spi" +) + +func DeleteSecret( + secretName string, + console spi.Console, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) error { + var err error + + secretName, err = validateSecretName(secretName) + if err == nil { + log.Printf("Secret name validated OK") + err = sendDeleteSecretRequest(secretName, apiClient, byteReader) + } + log.Printf("SecretsDelete exiting. err is %v\n", err) + return err +} + +func sendDeleteSecretRequest( + secretName string, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) error { + var err error + var httpResponse *http.Response + var context context.Context = context.Background() + var restApiVersion string + + restApiVersion, err = embedded.GetGalasactlRestApiVersion() + + if err == nil { + httpResponse, err = apiClient.SecretsAPIApi.DeleteSecret(context, secretName). + ClientApiVersion(restApiVersion). + Execute() + + if httpResponse != nil { + defer httpResponse.Body.Close() + } + + if err != nil { + if httpResponse == nil { + // We never got a response, error sending it or something? + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_DELETE_SECRET_REQUEST_FAILED, err.Error()) + } else { + err = galasaErrors.HttpResponseToGalasaError( + httpResponse, + secretName, + byteReader, + galasaErrors.GALASA_ERROR_DELETE_SECRET_NO_RESPONSE_CONTENT, + galasaErrors.GALASA_ERROR_DELETE_SECRET_RESPONSE_BODY_UNREADABLE, + galasaErrors.GALASA_ERROR_DELETE_SECRET_UNPARSEABLE_CONTENT, + galasaErrors.GALASA_ERROR_DELETE_SECRET_SERVER_REPORTED_ERROR, + galasaErrors.GALASA_ERROR_DELETE_SECRET_EXPLANATION_NOT_JSON, + ) + } + } + } + return err +} diff --git a/pkg/secrets/secretsDelete_test.go b/pkg/secrets/secretsDelete_test.go new file mode 100644 index 00000000..fa7067fd --- /dev/null +++ b/pkg/secrets/secretsDelete_test.go @@ -0,0 +1,274 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secrets + +import ( + "net/http" + "strconv" + "testing" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestCanDeleteASecret(t *testing.T) { + // Given... + secretName := "SYSTEM1" + + // Create the expected HTTP interactions with the API server + deleteSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodDelete) + deleteSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + interactions := []utils.HttpInteraction{ + deleteSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := DeleteSecret( + secretName, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "DeleteSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful deletion, it should be empty") +} + +func TestDeleteASecretWithBlankNameDisplaysError(t *testing.T) { + // Given... + secretName := " " + + // The client-side validation should fail, so no HTTP interactions will be performed + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := DeleteSecret( + secretName, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "DeleteSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1172E") + assert.Contains(t, errorMsg, " Invalid secret name provided") +} + +func TestDeleteNonExistantSecretDisplaysError(t *testing.T) { + // Given... + nonExistantSecret := "secretDoesNotExist123" + + // Create the expected HTTP interactions with the API server + deleteSecretInteraction := utils.NewHttpInteraction("/secrets/" + nonExistantSecret, http.MethodDelete) + deleteSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusNotFound) + writer.Write([]byte(`{ "error_message": "No such secret exists" }`)) + } + + + interactions := []utils.HttpInteraction{ deleteSecretInteraction } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := DeleteSecret( + nonExistantSecret, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsDelete did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg, nonExistantSecret) + assert.Contains(t, errorMsg, "GAL1170E") + assert.Contains(t, errorMsg, "Error details from the server are: 'No such secret exists'") +} + +func TestSecretsDeleteFailsWithNoExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + + // Create the expected HTTP interactions with the API server + deleteSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodDelete) + deleteSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + } + + interactions := []utils.HttpInteraction{ + deleteSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := DeleteSecret( + secretName, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsDelete did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText , secretName) + assert.Contains(t, consoleText , "GAL1167E") +} + +func TestSecretsDeleteFailsWithNonJsonContentTypeExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + + // Create the expected HTTP interactions with the API server + deleteSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodDelete) + deleteSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + writer.Header().Set("Content-Type", "application/notJsonOnPurpose") + writer.Write([]byte("something not json but non-zero-length.")) + } + + interactions := []utils.HttpInteraction{ + deleteSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := DeleteSecret( + secretName, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsDelete did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1171E") + assert.Contains(t, consoleText, "Error details from the server are not in the json format") +} + +func TestSecretsDeleteFailsWithBadlyFormedJsonContentExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + + // Create the expected HTTP interactions with the API server + deleteSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodDelete) + deleteSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{ "this": "isBadJson because it doesnt end in a close braces" `)) + } + + interactions := []utils.HttpInteraction{ + deleteSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := DeleteSecret( + secretName, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsDelete did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1169E") + assert.Contains(t, consoleText, "Error details from the server are not in a valid json format") + assert.Contains(t, consoleText, "Cause: 'unexpected end of JSON input'") +} + +func TestSecretsDeleteFailsWithFailureToReadResponseBodyGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + + // Create the expected HTTP interactions with the API server + deleteSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodDelete) + deleteSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{}`)) + } + + interactions := []utils.HttpInteraction{ + deleteSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReaderAsMock(true) + + // When... + err := DeleteSecret( + secretName, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsDelete returned an unexpected error") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1168E") + assert.Contains(t, consoleText, "Error details from the server could not be read") +} From ba08e2875a725c9a0403e5b7c53a3633948e3493 Mon Sep 17 00:00:00 2001 From: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:06:37 +0000 Subject: [PATCH 3/4] Add galasactl secrets get command and formatters (#301) * feat: Add galasactl secrets get command and formatters Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * feat: Add validation to secrets Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * Empty commit to kick off build Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --------- Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --- .secrets.baseline | 84 +++ README.md | 25 + docs/generated/errors-list.md | 12 + docs/generated/galasactl_secrets.md | 1 + docs/generated/galasactl_secrets_get.md | 32 + pkg/cmd/commandCollection.go | 7 + pkg/cmd/secretsGet.go | 150 ++++ pkg/cmd/secretsGet_test.go | 63 ++ pkg/errors/errorMessage.go | 14 + pkg/errors/galasaAPIError.go | 30 +- pkg/secrets/secretsGet.go | 203 ++++++ pkg/secrets/secretsGet_test.go | 646 ++++++++++++++++++ pkg/secretsformatter/GalasaSecret.go | 65 ++ pkg/secretsformatter/secretsFormatter.go | 66 ++ pkg/secretsformatter/summaryFormatter.go | 64 ++ pkg/secretsformatter/summaryFormatter_test.go | 114 ++++ pkg/secretsformatter/yamlFormatter.go | 54 ++ pkg/secretsformatter/yamlFormatter_test.go | 86 +++ 18 files changed, 1711 insertions(+), 5 deletions(-) create mode 100644 docs/generated/galasactl_secrets_get.md create mode 100644 pkg/cmd/secretsGet.go create mode 100644 pkg/cmd/secretsGet_test.go create mode 100644 pkg/secrets/secretsGet.go create mode 100644 pkg/secrets/secretsGet_test.go create mode 100644 pkg/secretsformatter/GalasaSecret.go create mode 100644 pkg/secretsformatter/secretsFormatter.go create mode 100644 pkg/secretsformatter/summaryFormatter.go create mode 100644 pkg/secretsformatter/summaryFormatter_test.go create mode 100644 pkg/secretsformatter/yamlFormatter.go create mode 100644 pkg/secretsformatter/yamlFormatter_test.go diff --git a/.secrets.baseline b/.secrets.baseline index 99a6d52b..ff805898 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -145,6 +145,90 @@ "type": "Secret Keyword", "verified_result": null } + ], + "pkg/secrets/secretsGet_test.go": [ + { + "hashed_secret": "11747ed2a3904f82931baf592443772259ea8dc1", + "is_secret": false, + "is_verified": false, + "line_number": 25, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "679d55ddc3c3d0f6ea2d11275a5d084669c98d56", + "is_secret": false, + "is_verified": false, + "line_number": 62, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "3b938c1150a71e71e5f1ffeadbe6475f0f6a2e36", + "is_secret": false, + "is_verified": false, + "line_number": 122, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "2dfbe3ec00a96d6f711d9a70f78be17f6fd574ca", + "is_secret": false, + "is_verified": false, + "line_number": 284, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secretsformatter/GalasaSecret.go": [ + { + "hashed_secret": "1949c4c92eb313637b3b6f654f5cce42df0dde88", + "is_secret": false, + "is_verified": false, + "line_number": 62, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secretsformatter/summaryFormatter.go": [ + { + "hashed_secret": "4d55af37dbbb6a42088d917caa1ca25428ec42c9", + "is_secret": false, + "is_verified": false, + "line_number": 44, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secretsformatter/summaryFormatter_test.go": [ + { + "hashed_secret": "11747ed2a3904f82931baf592443772259ea8dc1", + "is_secret": false, + "is_verified": false, + "line_number": 20, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secretsformatter/yamlFormatter.go": [ + { + "hashed_secret": "4d55af37dbbb6a42088d917caa1ca25428ec42c9", + "is_secret": false, + "is_verified": false, + "line_number": 34, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secretsformatter/yamlFormatter_test.go": [ + { + "hashed_secret": "679d55ddc3c3d0f6ea2d11275a5d084669c98d56", + "is_secret": false, + "is_verified": false, + "line_number": 29, + "type": "Secret Keyword", + "verified_result": null + } ] }, "version": "0.13.1+ibm.62.dss", diff --git a/README.md b/README.md index 8ff0f92c..31172abd 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,31 @@ galasactl resources delete -f my_resources.yaml For a complete list of supported parameters see [here](./docs/generated/galasactl_resources_delete.md). +## secrets get + +This command retrieves a list of secrets stored in the Galasa Ecosystem's credentials store. The retrieved secrets can be displayed in different formats, including `summary` and `yaml` formats, based on the value provided by the `--format` flag. If `--format` is not provided, secrets will be displayed in the `summary` format by default. + +### Examples + +All secrets stored in a Galasa Ecosystem can be retrieved using the following command: + +``` +galasactl secrets get +``` + +To get a specific secret named `SYSTEM1`, the `--name` flag can be provided as follows: + +``` +galasactl secrets get --name SYSTEM1 +``` + +To display a secret in a different format, like YAML, the `--format` flag can be provided: + +``` +galasactl secrets get --name SYSTEM1 --format yaml +``` + +For a complete list of supported parameters see [here](./docs/generated/galasactl_secrets_get.md). ## secrets delete diff --git a/docs/generated/errors-list.md b/docs/generated/errors-list.md index f535bcc8..7aa345c8 100644 --- a/docs/generated/errors-list.md +++ b/docs/generated/errors-list.md @@ -169,6 +169,18 @@ The `galasactl` tool can generate the following errors: - GAL1171E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in the json format. - GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty or contain spaces, and must only contain characters in the Latin-1 character set. - GAL1173E: An attempt to delete a secret named '{}' failed. Sending the delete request to the Galasa service failed. Cause is {} +- GAL1174E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. +- GAL1175E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server could not be read. Cause: {} +- GAL1176E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in a valid json format. Cause: '{}' +- GAL1177E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are: '{}' +- GAL1178E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in the json format. +- GAL1179E: An attempt to get a secret named '{}' failed. Sending the get request to the Galasa service failed. Cause is {} +- GAL1180E: Failed to get secrets. Unexpected http status code {} received from the server. +- GAL1181E: Failed to get secrets. Unexpected http status code {} received from the server. Error details from the server could not be read. Cause: {} +- GAL1182E: Failed to get secrets. Unexpected http status code {} received from the server. Error details from the server are not in a valid json format. Cause: '{}' +- GAL1183E: Failed to get secrets. Unexpected http status code {} received from the server. Error details from the server are: '{}' +- GAL1184E: Failed to get secrets. Unexpected http status code {} received from the server. Error details from the server are not in the json format. +- GAL1185E: Failed to get secrets. Sending the get request to the Galasa service failed. Cause is {} - GAL1225E: Failed to open file '{}' cause: {}. Check that this file exists, and that you have read permissions. - GAL1226E: Internal failure. Contents of gzip could be read, but not decoded. New gzip reader failed: file: {} error: {} - GAL1227E: Internal failure. Contents of gzip could not be decoded. {} error: {} diff --git a/docs/generated/galasactl_secrets.md b/docs/generated/galasactl_secrets.md index 48ac5e1b..d5418926 100644 --- a/docs/generated/galasactl_secrets.md +++ b/docs/generated/galasactl_secrets.md @@ -24,4 +24,5 @@ The parent command for operations to manipulate secrets in the Galasa service's * [galasactl](galasactl.md) - CLI for Galasa * [galasactl secrets delete](galasactl_secrets_delete.md) - Deletes a secret from the credentials store +* [galasactl secrets get](galasactl_secrets_get.md) - Get secrets from the credentials store diff --git a/docs/generated/galasactl_secrets_get.md b/docs/generated/galasactl_secrets_get.md new file mode 100644 index 00000000..e2ebfc3e --- /dev/null +++ b/docs/generated/galasactl_secrets_get.md @@ -0,0 +1,32 @@ +## galasactl secrets get + +Get secrets from the credentials store + +### Synopsis + +Get a list of secrets or a specific secret from the credentials store + +``` +galasactl secrets get [flags] +``` + +### Options + +``` + --format string the output format of the returned secrets. Supported formats are: 'summary', 'yaml'. (default "summary") + -h, --help Displays the options for the 'secrets get' command. + --name string An optional flag that identifies the secret to be retrieved. +``` + +### Options inherited from parent commands + +``` + -b, --bootstrap string Bootstrap URL. Should start with 'http://' or 'file://'. If it starts with neither, it is assumed to be a fully-qualified path. If missing, it defaults to use the 'bootstrap.properties' file in your GALASA_HOME. Example: http://example.com/bootstrap, file:///user/myuserid/.galasa/bootstrap.properties , file://C:/Users/myuserid/.galasa/bootstrap.properties + --galasahome string Path to a folder where Galasa will read and write files and configuration settings. The default is '${HOME}/.galasa'. This overrides the GALASA_HOME environment variable which may be set instead. + -l, --log string File to which log information will be sent. Any folder referred to must exist. An existing file will be overwritten. Specify "-" to log to stderr. Defaults to not logging. +``` + +### SEE ALSO + +* [galasactl secrets](galasactl_secrets.md) - Manage secrets stored in the Galasa service's credentials store + diff --git a/pkg/cmd/commandCollection.go b/pkg/cmd/commandCollection.go index 8ae99d8f..1d83c724 100644 --- a/pkg/cmd/commandCollection.go +++ b/pkg/cmd/commandCollection.go @@ -60,6 +60,7 @@ const ( COMMAND_NAME_RESOURCES_UPDATE = "resources update" COMMAND_NAME_RESOURCES_DELETE = "resources delete" COMMAND_NAME_SECRETS = "secrets" + COMMAND_NAME_SECRETS_GET = "secrets get" COMMAND_NAME_SECRETS_DELETE = "secrets delete" COMMAND_NAME_USERS = "users" COMMAND_NAME_USERS_GET = "users get" @@ -385,16 +386,22 @@ func (commands *commandCollectionImpl) addSecretsCommands(factory spi.Factory, r var err error var secretsCommand spi.GalasaCommand + var secretsGetCommand spi.GalasaCommand var secretsDeleteCommand spi.GalasaCommand secretsCommand, err = NewSecretsCmd(rootCommand) + if err == nil { + secretsGetCommand, err = NewSecretsGetCommand(factory, secretsCommand, rootCommand) + } + if err == nil { secretsDeleteCommand, err = NewSecretsDeleteCommand(factory, secretsCommand, rootCommand) } if err == nil { commands.commandMap[secretsCommand.Name()] = secretsCommand + commands.commandMap[secretsGetCommand.Name()] = secretsGetCommand commands.commandMap[secretsDeleteCommand.Name()] = secretsDeleteCommand } diff --git a/pkg/cmd/secretsGet.go b/pkg/cmd/secretsGet.go new file mode 100644 index 00000000..616212cd --- /dev/null +++ b/pkg/cmd/secretsGet.go @@ -0,0 +1,150 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "log" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/secrets" + "github.com/galasa-dev/cli/pkg/spi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/spf13/cobra" +) + +type SecretsGetCmdValues struct { + outputFormat string +} + +type SecretsGetCommand struct { + values *SecretsGetCmdValues + cobraCommand *cobra.Command +} + +// ------------------------------------------------------------------------------------------------ +// Constructors methods +// ------------------------------------------------------------------------------------------------ +func NewSecretsGetCommand( + factory spi.Factory, + secretsGetCommand spi.GalasaCommand, + rootCmd spi.GalasaCommand, +) (spi.GalasaCommand, error) { + + cmd := new(SecretsGetCommand) + + err := cmd.init(factory, secretsGetCommand, rootCmd) + return cmd, err +} + +// ------------------------------------------------------------------------------------------------ +// Public methods +// ------------------------------------------------------------------------------------------------ +func (cmd *SecretsGetCommand) Name() string { + return COMMAND_NAME_SECRETS_GET +} + +func (cmd *SecretsGetCommand) CobraCommand() *cobra.Command { + return cmd.cobraCommand +} + +func (cmd *SecretsGetCommand) Values() interface{} { + return cmd.values +} + +// ------------------------------------------------------------------------------------------------ +// Private methods +// ------------------------------------------------------------------------------------------------ +func (cmd *SecretsGetCommand) init(factory spi.Factory, secretsCommand spi.GalasaCommand, rootCmd spi.GalasaCommand) error { + var err error + + cmd.values = &SecretsGetCmdValues{} + cmd.cobraCommand, err = cmd.createCobraCmd(factory, secretsCommand, rootCmd.Values().(*RootCmdValues)) + + return err +} + +func (cmd *SecretsGetCommand) createCobraCmd( + factory spi.Factory, + secretsCommand spi.GalasaCommand, + rootCommandValues *RootCmdValues, +) (*cobra.Command, error) { + + var err error + + secretsCommandValues := secretsCommand.Values().(*SecretsCmdValues) + secretsGetCobraCmd := &cobra.Command{ + Use: "get", + Short: "Get secrets from the credentials store", + Long: "Get a list of secrets or a specific secret from the credentials store", + Aliases: []string{COMMAND_NAME_SECRETS_GET}, + RunE: func(cobraCommand *cobra.Command, args []string) error { + return cmd.executeSecretsGet(factory, secretsCommand.Values().(*SecretsCmdValues), rootCommandValues) + }, + } + + addSecretNameFlag(secretsGetCobraCmd, false, secretsCommandValues) + + formatters := secrets.GetFormatterNamesAsString() + secretsGetCobraCmd.Flags().StringVar(&cmd.values.outputFormat, "format", "summary", "the output format of the returned secrets. Supported formats are: "+formatters+".") + + secretsCommand.CobraCommand().AddCommand(secretsGetCobraCmd) + + return secretsGetCobraCmd, err +} + +func (cmd *SecretsGetCommand) executeSecretsGet( + factory spi.Factory, + secretsCmdValues *SecretsCmdValues, + rootCmdValues *RootCmdValues, +) error { + + var err error + // Operations on the file system will all be relative to the current folder. + fileSystem := factory.GetFileSystem() + + err = utils.CaptureLog(fileSystem, rootCmdValues.logFileName) + + if err == nil { + rootCmdValues.isCapturingLogs = true + + log.Println("Galasa CLI - Get secrets from the ecosystem") + + env := factory.GetEnvironment() + + var galasaHome spi.GalasaHome + galasaHome, err = utils.NewGalasaHome(fileSystem, env, rootCmdValues.CmdParamGalasaHomePath) + if err == nil { + + var urlService *api.RealUrlResolutionService = new(api.RealUrlResolutionService) + var bootstrapData *api.BootstrapData + bootstrapData, err = api.LoadBootstrap(galasaHome, fileSystem, env, secretsCmdValues.bootstrap, urlService) + if err == nil { + + var console = factory.GetStdOutConsole() + + apiServerUrl := bootstrapData.ApiServerURL + log.Printf("The API server is at '%s'\n", apiServerUrl) + + authenticator := factory.GetAuthenticator( + apiServerUrl, + galasaHome, + ) + + var apiClient *galasaapi.APIClient + apiClient, err = authenticator.GetAuthenticatedAPIClient() + + byteReader := factory.GetByteReader() + + if err == nil { + err = secrets.GetSecrets(secretsCmdValues.name, cmd.values.outputFormat, console, apiClient, byteReader) + } + } + } + } + + return err +} diff --git a/pkg/cmd/secretsGet_test.go b/pkg/cmd/secretsGet_test.go new file mode 100644 index 00000000..944d4be1 --- /dev/null +++ b/pkg/cmd/secretsGet_test.go @@ -0,0 +1,63 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "testing" + + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestCommandListContainsSecretsGetCommand(t *testing.T) { + /// Given... + factory := utils.NewMockFactory() + commands, _ := NewCommandCollection(factory) + + // When... + secretsCommand, err := commands.GetCommand(COMMAND_NAME_SECRETS_GET) + assert.Nil(t, err) + + // Then... + assert.NotNil(t, secretsCommand) + assert.Equal(t, COMMAND_NAME_SECRETS_GET, secretsCommand.Name()) + assert.NotNil(t, secretsCommand.Values()) + assert.IsType(t, &SecretsGetCmdValues{}, secretsCommand.Values()) +} + +func TestSecretsGetHelpFlagSetCorrectly(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + commandCollection, _ := setupTestCommandCollection(COMMAND_NAME_SECRETS_GET, factory, t) + + var args []string = []string{"secrets", "get", "--help"} + + // When... + err := commandCollection.Execute(args) + + // Then... + checkOutput("Get a list of secrets or a specific secret from the credentials store", "", factory, t) + + assert.Nil(t, err) +} + +func TestSecretsGetNoFlagsReturnsOk(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + commandCollection, _ := setupTestCommandCollection(COMMAND_NAME_SECRETS_GET, factory, t) + + var args []string = []string{"secrets", "get"} + + // When... + err := commandCollection.Execute(args) + + // Then... + assert.Nil(t, err) + + // Check what the user saw is reasonable. + checkOutput("", "", factory, t) +} + diff --git a/pkg/errors/errorMessage.go b/pkg/errors/errorMessage.go index decf13f1..5c586d18 100644 --- a/pkg/errors/errorMessage.go +++ b/pkg/errors/errorMessage.go @@ -271,6 +271,20 @@ var ( GALASA_ERROR_INVALID_SECRET_NAME = NewMessageType("GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty or contain spaces, and must only contain characters in the Latin-1 character set.", 1172, STACK_TRACE_NOT_WANTED) GALASA_ERROR_DELETE_SECRET_REQUEST_FAILED = NewMessageType("GAL1173E: An attempt to delete a secret named '%s' failed. Sending the delete request to the Galasa service failed. Cause is %v", 1173, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRET_NO_RESPONSE_CONTENT = NewMessageType("GAL1174E: An attempt to get a secret named '%s' failed. Unexpected http status code %v received from the server.", 1174, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRET_RESPONSE_BODY_UNREADABLE = NewMessageType("GAL1175E: An attempt to get a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server could not be read. Cause: %s", 1175, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRET_UNPARSEABLE_CONTENT = NewMessageType("GAL1176E: An attempt to get a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1176, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRET_SERVER_REPORTED_ERROR = NewMessageType("GAL1177E: An attempt to get a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1177, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRET_EXPLANATION_NOT_JSON = NewMessageType("GAL1178E: An attempt to get a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1178, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRET_REQUEST_FAILED = NewMessageType("GAL1179E: An attempt to get a secret named '%s' failed. Sending the get request to the Galasa service failed. Cause is %v", 1179, STACK_TRACE_NOT_WANTED) + + GALASA_ERROR_GET_SECRETS_NO_RESPONSE_CONTENT = NewMessageType("GAL1180E: Failed to get secrets. Unexpected http status code %v received from the server.", 1180, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRETS_RESPONSE_BODY_UNREADABLE = NewMessageType("GAL1181E: Failed to get secrets. Unexpected http status code %v received from the server. Error details from the server could not be read. Cause: %s", 1181, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRETS_UNPARSEABLE_CONTENT = NewMessageType("GAL1182E: Failed to get secrets. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1182, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRETS_SERVER_REPORTED_ERROR = NewMessageType("GAL1183E: Failed to get secrets. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1183, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRETS_EXPLANATION_NOT_JSON = NewMessageType("GAL1184E: Failed to get secrets. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1184, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_GET_SECRETS_REQUEST_FAILED = NewMessageType("GAL1185E: Failed to get secrets. Sending the get request to the Galasa service failed. Cause is %v", 1185, STACK_TRACE_NOT_WANTED) + // Warnings... GALASA_WARNING_MAVEN_NO_GALASA_OBR_REPO = NewMessageType("GAL2000W: Warning: Maven configuration file settings.xml should contain a reference to a Galasa repository so that the galasa OBR can be resolved. The official release repository is '%s', and 'pre-release' repository is '%s'", 2000, STACK_TRACE_WANTED) diff --git a/pkg/errors/galasaAPIError.go b/pkg/errors/galasaAPIError.go index bce7a074..ef3cf0b9 100644 --- a/pkg/errors/galasaAPIError.go +++ b/pkg/errors/galasaAPIError.go @@ -62,16 +62,16 @@ func HttpResponseToGalasaError( if response.ContentLength == 0 { log.Printf("Failed - HTTP response - status code: '%v'\n", statusCode) - err = NewGalasaError(errorMsgUnexpectedStatusCodeNoResponseBody, identifier, statusCode) + err = createResponseError(errorMsgUnexpectedStatusCodeNoResponseBody, identifier, statusCode) } else { contentType := response.Header.Get("Content-Type") if contentType != "application/json" { - err = NewGalasaError(errorMsgResponseContentTypeNotJson, identifier, statusCode) + err = createResponseError(errorMsgResponseContentTypeNotJson, identifier, statusCode) } else { responseBodyBytes, err = byteReader.ReadAll(response.Body) if err != nil { - err = NewGalasaError(errorMsgUnableToReadResponseBody, identifier, statusCode, err.Error()) + err = createResponseErrorWithCause(errorMsgUnableToReadResponseBody, identifier, statusCode, err.Error()) } else { var errorFromServer *GalasaAPIError @@ -79,17 +79,37 @@ func HttpResponseToGalasaError( responseBodyBytes, func (marshallingError error) error { log.Printf("Failed - HTTP response - status code: '%v' payload in response is not json: '%v' \n", statusCode, string(responseBodyBytes)) - return NewGalasaError(errorMsgResponsePayloadInWrongFormat, identifier, statusCode, marshallingError) + return createResponseErrorWithCause(errorMsgResponsePayloadInWrongFormat, identifier, statusCode, marshallingError) }, ) if err == nil { // server returned galasa api error structure we understand. log.Printf("Failed - HTTP response - status code: '%v' server responded with error message: '%v' \n", statusCode, errorMsgReceivedFromApiServer) - err = NewGalasaError(errorMsgReceivedFromApiServer, identifier, statusCode, errorFromServer.Message) + err = createResponseErrorWithCause(errorMsgReceivedFromApiServer, identifier, statusCode, errorFromServer.Message) } } } } return err } + +func createResponseError(errorMsg *MessageType, identifier string, statusCode int) error { + var err error + if identifier == "" { + err = NewGalasaError(errorMsg, statusCode) + } else { + err = NewGalasaError(errorMsg, identifier, statusCode) + } + return err +} + +func createResponseErrorWithCause(errorMsg *MessageType, identifier string, statusCode int, cause interface{}) error { + var err error + if identifier == "" { + err = NewGalasaError(errorMsg, statusCode, cause) + } else { + err = NewGalasaError(errorMsg, identifier, statusCode, cause) + } + return err +} diff --git a/pkg/secrets/secretsGet.go b/pkg/secrets/secretsGet.go new file mode 100644 index 00000000..00287ec8 --- /dev/null +++ b/pkg/secrets/secretsGet.go @@ -0,0 +1,203 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package secrets + +import ( + "context" + "log" + "net/http" + "sort" + "strings" + + "github.com/galasa-dev/cli/pkg/embedded" + galasaErrors "github.com/galasa-dev/cli/pkg/errors" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/secretsformatter" + "github.com/galasa-dev/cli/pkg/spi" +) + +var ( + formatters = createFormatters() +) + +func GetSecrets( + secretName string, + format string, + console spi.Console, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) error { + var err error + var chosenFormatter secretsformatter.SecretsFormatter + secrets := make([]galasaapi.GalasaSecret, 0) + + chosenFormatter, err = validateFormatFlag(format) + if err == nil { + if secretName != "" { + // The user has provided a secret name, so try to get that secret + var secret *galasaapi.GalasaSecret + secret, err = getSecretByName(secretName, apiClient, byteReader) + if err == nil { + secrets = append(secrets, *secret) + } + } else { + // Get all secrets + secrets, err = getSecretsFromRestApi(apiClient, byteReader) + } + + // If we were able to get the secrets, format them as requested by the user + if err == nil { + var formattedOutput string + formattedOutput, err = chosenFormatter.FormatSecrets(secrets) + if err == nil { + console.WriteString(formattedOutput) + } + } + } + log.Printf("GetSecrets exiting. err is %v\n", err) + return err +} + +func getSecretByName( + secretName string, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) (*galasaapi.GalasaSecret, error) { + var err error + var secret *galasaapi.GalasaSecret + secretName, err = validateSecretName(secretName) + if err == nil { + secret, err = getSecretFromRestApi(secretName, apiClient, byteReader) + } + + return secret, err +} + +func getSecretFromRestApi( + secretName string, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) (*galasaapi.GalasaSecret, error) { + var err error + var httpResponse *http.Response + var context context.Context = context.Background() + var restApiVersion string + var secret *galasaapi.GalasaSecret + + restApiVersion, err = embedded.GetGalasactlRestApiVersion() + + if err == nil { + secret, httpResponse, err = apiClient.SecretsAPIApi.GetSecret(context, secretName). + ClientApiVersion(restApiVersion). + Execute() + + if httpResponse != nil { + defer httpResponse.Body.Close() + } + + if err != nil { + if httpResponse == nil { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_GET_SECRET_REQUEST_FAILED, err.Error()) + } else { + err = galasaErrors.HttpResponseToGalasaError( + httpResponse, + secretName, + byteReader, + galasaErrors.GALASA_ERROR_GET_SECRET_NO_RESPONSE_CONTENT, + galasaErrors.GALASA_ERROR_GET_SECRET_RESPONSE_BODY_UNREADABLE, + galasaErrors.GALASA_ERROR_GET_SECRET_UNPARSEABLE_CONTENT, + galasaErrors.GALASA_ERROR_GET_SECRET_SERVER_REPORTED_ERROR, + galasaErrors.GALASA_ERROR_GET_SECRET_EXPLANATION_NOT_JSON, + ) + } + } + } + return secret, err +} + +func getSecretsFromRestApi( + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) ([]galasaapi.GalasaSecret, error) { + var err error + var httpResponse *http.Response + var context context.Context = context.Background() + var restApiVersion string + var secrets []galasaapi.GalasaSecret + + restApiVersion, err = embedded.GetGalasactlRestApiVersion() + + if err == nil { + secrets, httpResponse, err = apiClient.SecretsAPIApi.GetSecrets(context). + ClientApiVersion(restApiVersion). + Execute() + + if httpResponse != nil { + defer httpResponse.Body.Close() + } + + if err != nil { + if httpResponse == nil { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_GET_SECRETS_REQUEST_FAILED, err.Error()) + } else { + err = galasaErrors.HttpResponseToGalasaError( + httpResponse, + "", + byteReader, + galasaErrors.GALASA_ERROR_GET_SECRETS_NO_RESPONSE_CONTENT, + galasaErrors.GALASA_ERROR_GET_SECRETS_RESPONSE_BODY_UNREADABLE, + galasaErrors.GALASA_ERROR_GET_SECRETS_UNPARSEABLE_CONTENT, + galasaErrors.GALASA_ERROR_GET_SECRETS_SERVER_REPORTED_ERROR, + galasaErrors.GALASA_ERROR_GET_SECRETS_EXPLANATION_NOT_JSON, + ) + } + } + } + return secrets, err +} + +func createFormatters() map[string]secretsformatter.SecretsFormatter { + formatters := make(map[string]secretsformatter.SecretsFormatter, 0) + summaryFormatter := secretsformatter.NewSecretSummaryFormatter() + yamlFormatter := secretsformatter.NewSecretYamlFormatter() + + formatters[summaryFormatter.GetName()] = summaryFormatter + formatters[yamlFormatter.GetName()] = yamlFormatter + + return formatters +} + +func GetFormatterNamesAsString() string { + names := make([]string, 0, len(formatters)) + for name := range formatters { + names = append(names, name) + } + sort.Strings(names) + formatterNames := strings.Builder{} + + for index, formatterName := range names { + + if index != 0 { + formatterNames.WriteString(", ") + } + formatterNames.WriteString("'" + formatterName + "'") + } + + return formatterNames.String() +} + +func validateFormatFlag(outputFormatString string) (secretsformatter.SecretsFormatter, error) { + var err error + + chosenFormatter, isPresent := formatters[outputFormatString] + + if !isPresent { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_INVALID_OUTPUT_FORMAT, outputFormatString, GetFormatterNamesAsString()) + } + + return chosenFormatter, err +} diff --git a/pkg/secrets/secretsGet_test.go b/pkg/secrets/secretsGet_test.go new file mode 100644 index 00000000..de3310e6 --- /dev/null +++ b/pkg/secrets/secretsGet_test.go @@ -0,0 +1,646 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secrets + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "testing" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +const ( + API_VERSION = "galasa-dev/v1alpha1" + DUMMY_ENCODING = "myencoding" + DUMMY_USERNAME = "dummy-username" + DUMMY_PASSWORD = "dummy-password" +) + +func createMockGalasaSecret(secretName string, description string) galasaapi.GalasaSecret { + secret := *galasaapi.NewGalasaSecret() + + secret.SetApiVersion(API_VERSION) + secret.SetKind("GalasaSecret") + + secretMetadata := *galasaapi.NewGalasaSecretMetadata() + secretMetadata.SetName(secretName) + secretMetadata.SetEncoding(DUMMY_ENCODING) + secretMetadata.SetType("UsernamePassword") + + if description != "" { + secretMetadata.SetDescription(description) + } + + secretData := *galasaapi.NewGalasaSecretData() + secretData.SetUsername(DUMMY_USERNAME) + secretData.SetPassword(DUMMY_PASSWORD) + + secret.SetMetadata(secretMetadata) + secret.SetData(secretData) + return secret +} + +func generateExpectedSecretYaml(secretName string, description string) string { + return fmt.Sprintf(`apiVersion: %s +kind: GalasaSecret +metadata: + name: %s + description: %s + encoding: %s + type: UsernamePassword +data: + username: %s + password: %s`, API_VERSION, secretName, description, DUMMY_ENCODING, DUMMY_USERNAME, DUMMY_PASSWORD) +} + +func TestCanGetASecretByName(t *testing.T) { + // Given... + secretName := "SYSTEM1" + description := "my SYSTEM1 secret" + outputFormat := "summary" + + // Create the mock secret to return + secret := createMockGalasaSecret(secretName, description) + secretBytes, _ := json.Marshal(secret) + secretJson := string(secretBytes) + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write([]byte(secretJson)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + expectedOutput := fmt.Sprintf( +`name type description +%s UsernamePassword %s + +Total:1 +`, secretName, description) + assert.Nil(t, err, "GetSecrets returned an unexpected error") + assert.Equal(t, expectedOutput, console.ReadText()) +} + +func TestCanGetASecretByNameInYamlFormat(t *testing.T) { + // Given... + secretName := "SYSTEM1" + description := "my SYSTEM1 secret" + outputFormat := "yaml" + + // Create the mock secret to return + secret := createMockGalasaSecret(secretName, description) + secretBytes, _ := json.Marshal(secret) + secretJson := string(secretBytes) + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write([]byte(secretJson)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + expectedOutput := generateExpectedSecretYaml(secretName, description) + "\n" + assert.Nil(t, err, "GetSecrets returned an unexpected error") + assert.Equal(t, expectedOutput, console.ReadText()) +} + +func TestCanGetAllSecretsOk(t *testing.T) { + // Given... + // Don't provide a secret name so that we can get all secrets + secretName := "" + outputFormat := "summary" + + // Create the mock secret to return + secrets := make([]galasaapi.GalasaSecret, 0) + secret1Name := "BOB" + secret2Name := "BLAH" + description1 := "my BOB secret" + description2 := "my BLAH secret" + secret1 := createMockGalasaSecret(secret1Name, description1) + secret2 := createMockGalasaSecret(secret2Name, description2) + + secrets = append(secrets, secret1, secret2) + secretsBytes, _ := json.Marshal(secrets) + secretsJson := string(secretsBytes) + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets", http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write([]byte(secretsJson)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + expectedOutput := fmt.Sprintf( +`name type description +%s UsernamePassword %s +%s UsernamePassword %s + +Total:2 +`, secret1Name, description1, secret2Name, description2) + assert.Nil(t, err, "GetSecrets returned an unexpected error") + assert.Equal(t, expectedOutput, console.ReadText()) +} + +func TestGetASecretWithUnknownFormatDisplaysError(t *testing.T) { + // Given... + secretName := "MYSECRET" + outputFormat := "UNKNOWN FORMAT!" + + // The client-side validation should fail, so no HTTP interactions will be performed + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "GetSecrets did not return an error as expected") + consoleOutputText := err.Error() + assert.Contains(t, consoleOutputText, "GAL1067E") + assert.Contains(t, consoleOutputText, "Unsupported value 'UNKNOWN FORMAT!'") + assert.Contains(t, consoleOutputText, "'summary', 'yaml'") +} + +func TestGetASecretWithBlankNameDisplaysError(t *testing.T) { + // Given... + secretName := " " + outputFormat := "summary" + + // The client-side validation should fail, so no HTTP interactions will be performed + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "GetSecrets did not return an error as expected") + consoleOutputText := err.Error() + assert.Contains(t, consoleOutputText, "GAL1172E") + assert.Contains(t, consoleOutputText, " Invalid secret name provided") +} + +func TestGetNonExistantSecretDisplaysError(t *testing.T) { + // Given... + nonExistantSecret := "secretDoesNotExist123" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + nonExistantSecret, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusNotFound) + writer.Write([]byte(`{ "error_message": "No such secret exists" }`)) + } + + + interactions := []utils.HttpInteraction{ getSecretInteraction } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + nonExistantSecret, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + consoleOutputText := err.Error() + assert.Contains(t, consoleOutputText, nonExistantSecret) + assert.Contains(t, consoleOutputText, "GAL1177E") + assert.Contains(t, consoleOutputText, "Error details from the server are: 'No such secret exists'") +} + +func TestSecretsGetFailsWithNoExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg , secretName) + assert.Contains(t, errorMsg , "GAL1174E") +} + +func TestSecretsGetFailsWithNonJsonContentTypeExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + writer.Header().Set("Content-Type", "application/notJsonOnPurpose") + writer.Write([]byte("something not json but non-zero-length.")) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg, secretName) + assert.Contains(t, errorMsg, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, errorMsg, "GAL1178E") + assert.Contains(t, errorMsg, "Error details from the server are not in the json format") +} + +func TestSecretsGetFailsWithBadlyFormedJsonContentExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{ "this": "isBadJson because it doesnt end in a close braces" `)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg, secretName) + assert.Contains(t, errorMsg, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, errorMsg, "GAL1176E") + assert.Contains(t, errorMsg, "Error details from the server are not in a valid json format") + assert.Contains(t, errorMsg, "Cause: 'unexpected end of JSON input'") +} + +func TestSecretsGetFailsWithFailureToReadResponseBodyGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{}`)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReaderAsMock(true) + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet returned an unexpected error") + errorMsg := err.Error() + assert.Contains(t, errorMsg, secretName) + assert.Contains(t, errorMsg, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, errorMsg, "GAL1175E") + assert.Contains(t, errorMsg, "Error details from the server could not be read") +} + +func TestGetAllSecretsFailsWithNoExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets", http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg , "GAL1180E") +} + +func TestGetAllSecretsFailsWithNonJsonContentTypeExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets", http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + writer.Header().Set("Content-Type", "application/notJsonOnPurpose") + writer.Write([]byte("something not json but non-zero-length.")) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, errorMsg, "GAL1184E") + assert.Contains(t, errorMsg, "Error details from the server are not in the json format") +} + +func TestGetAllSecretsFailsWithBadlyFormedJsonContentExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets", http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{ "this": "isBadJson because it doesnt end in a close braces" `)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet did not return an error but it should have") + errorMsg := err.Error() + assert.Contains(t, errorMsg, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, errorMsg, "GAL1182E") + assert.Contains(t, errorMsg, "Error details from the server are not in a valid json format") + assert.Contains(t, errorMsg, "Cause: 'unexpected end of JSON input'") +} + +func TestGetAllSecretsFailsWithFailureToReadResponseBodyGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "" + outputFormat := "summary" + + // Create the expected HTTP interactions with the API server + getSecretInteraction := utils.NewHttpInteraction("/secrets", http.MethodGet) + getSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{}`)) + } + + interactions := []utils.HttpInteraction{ + getSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReaderAsMock(true) + + // When... + err := GetSecrets( + secretName, + outputFormat, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SecretsGet returned an unexpected error") + errorMsg := err.Error() + assert.Contains(t, errorMsg, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, errorMsg, "GAL1181E") + assert.Contains(t, errorMsg, "Error details from the server could not be read") +} diff --git a/pkg/secretsformatter/GalasaSecret.go b/pkg/secretsformatter/GalasaSecret.go new file mode 100644 index 00000000..37c48674 --- /dev/null +++ b/pkg/secretsformatter/GalasaSecret.go @@ -0,0 +1,65 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secretsformatter + +import ( + "time" + + "github.com/galasa-dev/cli/pkg/galasaapi" +) + +// The auto-generated OpenAPI structs don't include `yaml` annotations, which causes +// issues when it comes to marshalling data into GalasaSecret structs in order to display +// secrets in YAML format. This is a manually-maintained struct that includes `yaml` annotations. +type GalasaSecret struct { + ApiVersion *string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` + Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` + Metadata *GalasaSecretMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Data *GalasaSecretData `json:"data,omitempty" yaml:"data,omitempty"` +} + +type GalasaSecretMetadata struct { + Name *string `json:"name,omitempty" yaml:"name,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + LastUpdatedTime *time.Time `json:"lastUpdatedTime,omitempty" yaml:"lastUpdatedTime,omitempty"` + LastUpdatedBy *string `json:"lastUpdatedBy,omitempty" yaml:"lastUpdatedBy,omitempty"` + Encoding *string `json:"encoding,omitempty" yaml:"encoding,omitempty"` + Type *galasaapi.GalasaSecretType `json:"type,omitempty" yaml:"type,omitempty"` +} + +type GalasaSecretData struct { + Username *string `json:"username,omitempty" yaml:"username,omitempty"` + Password *string `json:"password,omitempty" yaml:"password,omitempty"` + Token *string `json:"token,omitempty" yaml:"token,omitempty"` +} + +func NewGalasaSecret(secret galasaapi.GalasaSecret) *GalasaSecret { + return &GalasaSecret{ + ApiVersion: secret.ApiVersion, + Kind: secret.Kind, + Metadata: NewGalasaSecretMetadata(secret.Metadata), + Data: NewGalasaSecretData(secret.Data), + } +} + +func NewGalasaSecretMetadata(metadata *galasaapi.GalasaSecretMetadata) *GalasaSecretMetadata { + return &GalasaSecretMetadata{ + Name: metadata.Name, + Description: metadata.Description, + LastUpdatedTime: metadata.LastUpdatedTime, + LastUpdatedBy: metadata.LastUpdatedBy, + Encoding: metadata.Encoding, + Type: metadata.Type, + } +} + +func NewGalasaSecretData(data *galasaapi.GalasaSecretData) *GalasaSecretData { + return &GalasaSecretData{ + Username: data.Username, + Password: data.Password, + Token: data.Token, + } +} \ No newline at end of file diff --git a/pkg/secretsformatter/secretsFormatter.go b/pkg/secretsformatter/secretsFormatter.go new file mode 100644 index 00000000..acdf2a7c --- /dev/null +++ b/pkg/secretsformatter/secretsFormatter.go @@ -0,0 +1,66 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package secretsformatter + +import ( + "fmt" + "strings" + + "github.com/galasa-dev/cli/pkg/galasaapi" +) + +// Displays secrets in the following format: +// name +// SYSTEM1 +// MY_ZOS_SECRET +// ANOTHER-SECRET +// Total:3 + +// ----------------------------------------------------- +// SecretsFormatter - implementations can take a collection of secrets +// and turn them into a string for display to the user. +const ( + HEADER_SECRET_NAME = "name" + HEADER_SECRET_TYPE = "type" + HEADER_SECRET_DESCRIPTION = "description" +) + +type SecretsFormatter interface { + FormatSecrets(secrets []galasaapi.GalasaSecret) (string, error) + GetName() string +} + +// ----------------------------------------------------- +// Functions for tables +func calculateMaxLengthOfEachColumn(table [][]string) []int { + columnLengths := make([]int, len(table[0])) + for _, row := range table { + for i, val := range row { + if len(val) > columnLengths[i] { + columnLengths[i] = len(val) + } + } + } + return columnLengths +} + +func writeFormattedTableToStringBuilder(table [][]string, buff *strings.Builder, columnLengths []int) { + for _, row := range table { + for column, val := range row { + + // For every column except the last one, add spacing. + if column < len(row)-1 { + // %-*s : variable space-padding length, padding is on the right. + buff.WriteString(fmt.Sprintf("%-*s", columnLengths[column], val)) + buff.WriteString(" ") + } else { + buff.WriteString(val) + } + } + buff.WriteString("\n") + } +} diff --git a/pkg/secretsformatter/summaryFormatter.go b/pkg/secretsformatter/summaryFormatter.go new file mode 100644 index 00000000..1c5ca849 --- /dev/null +++ b/pkg/secretsformatter/summaryFormatter.go @@ -0,0 +1,64 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secretsformatter + +import ( + "strconv" + "strings" + + "github.com/galasa-dev/cli/pkg/galasaapi" +) + +// ----------------------------------------------------- +// Summary format. +const ( + SUMMARY_FORMATTER_NAME = "summary" +) + +type SecretSummaryFormatter struct { +} + +func NewSecretSummaryFormatter() SecretsFormatter { + return new(SecretSummaryFormatter) +} + +func (*SecretSummaryFormatter) GetName() string { + return SUMMARY_FORMATTER_NAME +} + +func (*SecretSummaryFormatter) FormatSecrets(secrets []galasaapi.GalasaSecret) (string, error) { + var result string = "" + var err error = nil + buff := strings.Builder{} + totalSecrets := len(secrets) + + if totalSecrets > 0 { + var table [][]string + + var headers = []string{ HEADER_SECRET_NAME, HEADER_SECRET_TYPE, HEADER_SECRET_DESCRIPTION } + + table = append(table, headers) + for _, secret := range secrets { + var line []string + name := secret.Metadata.GetName() + secretType := secret.Metadata.GetType() + secretDescription := secret.Metadata.GetDescription() + + line = append(line, name, string(secretType), secretDescription) + table = append(table, line) + } + + columnLengths := calculateMaxLengthOfEachColumn(table) + writeFormattedTableToStringBuilder(table, &buff, columnLengths) + + buff.WriteString("\n") + + } + buff.WriteString("Total:" + strconv.Itoa(totalSecrets) + "\n") + + result = buff.String() + return result, err +} diff --git a/pkg/secretsformatter/summaryFormatter_test.go b/pkg/secretsformatter/summaryFormatter_test.go new file mode 100644 index 00000000..7324a640 --- /dev/null +++ b/pkg/secretsformatter/summaryFormatter_test.go @@ -0,0 +1,114 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secretsformatter + +import ( + "fmt" + "testing" + + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/stretchr/testify/assert" +) + +const ( + API_VERSION = "galasa-dev/v1alpha1" + DUMMY_ENCODING = "myencoding" + DUMMY_USERNAME = "dummy-username" + DUMMY_PASSWORD = "dummy-password" +) + +func createMockGalasaSecretWithDescription(secretName string, description string) galasaapi.GalasaSecret { + secret := *galasaapi.NewGalasaSecret() + + secret.SetApiVersion(API_VERSION) + secret.SetKind("GalasaSecret") + + secretMetadata := *galasaapi.NewGalasaSecretMetadata() + secretMetadata.SetName(secretName) + secretMetadata.SetEncoding(DUMMY_ENCODING) + secretMetadata.SetType("UsernamePassword") + + if description != "" { + secretMetadata.SetDescription(description) + } + + secretData := *galasaapi.NewGalasaSecretData() + secretData.SetUsername(DUMMY_USERNAME) + secretData.SetPassword(DUMMY_PASSWORD) + + secret.SetMetadata(secretMetadata) + secret.SetData(secretData) + return secret +} + +func TestSecretSummaryFormatterNoDataReturnsTotalCountAllZeros(t *testing.T) { + // Given... + formatter := NewSecretSummaryFormatter() + secrets := make([]galasaapi.GalasaSecret, 0) + + // When... + actualFormattedOutput, err := formatter.FormatSecrets(secrets) + + // Then... + assert.Nil(t, err) + expectedFormattedOutput := "Total:0\n" + assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) +} + +func TestSecretSummaryFormatterSingleDataReturnsCorrectly(t *testing.T) { + // Given... + formatter := NewSecretSummaryFormatter() + description := "secret for system1" + secretName := "MYSECRET" + secret1 := createMockGalasaSecretWithDescription(secretName, description) + secrets := []galasaapi.GalasaSecret{ secret1 } + + // When... + actualFormattedOutput, err := formatter.FormatSecrets(secrets) + + // Then... + assert.Nil(t, err) + expectedFormattedOutput := fmt.Sprintf( +`name type description +%s UsernamePassword %s + +Total:1 +`, secretName, description) + assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) +} + +func TestSecretSummaryFormatterMultipleDataSeperatesWithNewLine(t *testing.T) { + // Given.. + formatter := NewSecretSummaryFormatter() + secrets := make([]galasaapi.GalasaSecret, 0) + + secret1Name := "SECRET1" + secret1Description := "my first secret" + secret2Name := "SECRET2" + secret2Description := "my second secret" + secret3Name := "SECRET3" + secret3Description := "my third secret" + + secret1 := createMockGalasaSecretWithDescription(secret1Name, secret1Description) + secret2 := createMockGalasaSecretWithDescription(secret2Name, secret2Description) + secret3 := createMockGalasaSecretWithDescription(secret3Name, secret3Description) + secrets = append(secrets, secret1, secret2, secret3) + + // When... + actualFormattedOutput, err := formatter.FormatSecrets(secrets) + + // Then... + assert.Nil(t, err) + expectedFormattedOutput := fmt.Sprintf( +`name type description +%s UsernamePassword %s +%s UsernamePassword %s +%s UsernamePassword %s + +Total:3 +`, secret1Name, secret1Description, secret2Name, secret2Description, secret3Name, secret3Description) + assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) +} diff --git a/pkg/secretsformatter/yamlFormatter.go b/pkg/secretsformatter/yamlFormatter.go new file mode 100644 index 00000000..2f5865f7 --- /dev/null +++ b/pkg/secretsformatter/yamlFormatter.go @@ -0,0 +1,54 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secretsformatter + +import ( + "strings" + + "github.com/galasa-dev/cli/pkg/galasaapi" + "gopkg.in/yaml.v3" +) + +const ( + YAML_FORMATTER_NAME = "yaml" +) + +type SecretYamlFormatter struct { +} + +func NewSecretYamlFormatter() SecretsFormatter { + return new(SecretYamlFormatter) +} + +func (*SecretYamlFormatter) GetName() string { + return YAML_FORMATTER_NAME +} + +func (*SecretYamlFormatter) FormatSecrets(secrets []galasaapi.GalasaSecret) (string, error) { + var err error + buff := strings.Builder{} + + for index, secret := range secrets { + galasaSecret := NewGalasaSecret(secret) + secretString := "" + + if index > 0 { + secretString += "---\n" + } + + var yamlRepresentationBytes []byte + yamlRepresentationBytes, err = yaml.Marshal(galasaSecret) + if err == nil { + yamlStr := string(yamlRepresentationBytes) + secretString += yamlStr + } + + buff.WriteString(secretString) + } + + result := buff.String() + return result, err +} diff --git a/pkg/secretsformatter/yamlFormatter_test.go b/pkg/secretsformatter/yamlFormatter_test.go new file mode 100644 index 00000000..714e80a3 --- /dev/null +++ b/pkg/secretsformatter/yamlFormatter_test.go @@ -0,0 +1,86 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secretsformatter + +import ( + "fmt" + "testing" + + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/stretchr/testify/assert" +) + +func createMockGalasaSecret(secretName string) galasaapi.GalasaSecret { + return createMockGalasaSecretWithDescription(secretName, "") +} + +func generateExpectedSecretYaml(secretName string) string { + return fmt.Sprintf(`apiVersion: %s +kind: GalasaSecret +metadata: + name: %s + encoding: %s + type: UsernamePassword +data: + username: %s + password: %s`, API_VERSION, secretName, DUMMY_ENCODING, DUMMY_USERNAME, DUMMY_PASSWORD) +} + +func TestSecretsYamlFormatterNoDataReturnsBlankString(t *testing.T) { + // Given... + formatter := NewSecretYamlFormatter() + formattableSecret := make([]galasaapi.GalasaSecret, 0) + + // When... + actualFormattedOutput, err := formatter.FormatSecrets(formattableSecret) + + // Then... + assert.Nil(t, err) + expectedFormattedOutput := "" + assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) +} + +func TestSecretsYamlFormatterSingleDataReturnsCorrectly(t *testing.T) { + // Given.. + formatter := NewSecretYamlFormatter() + formattableSecrets := make([]galasaapi.GalasaSecret, 0) + secretName := "SECRET1" + secret1 := createMockGalasaSecret(secretName) + formattableSecrets = append(formattableSecrets, secret1) + + // When... + actualFormattedOutput, err := formatter.FormatSecrets(formattableSecrets) + + // Then... + assert.Nil(t, err) + expectedFormattedOutput := generateExpectedSecretYaml(secretName) + "\n" + assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) +} + +func TestSecretsYamlFormatterMultipleDataSeperatesWithNewLine(t *testing.T) { + // For.. + formatter := NewSecretYamlFormatter() + formattableSecrets := make([]galasaapi.GalasaSecret, 0) + + secret1Name := "MYSECRET" + secret2Name := "MY-NEXT-SECRET" + secret1 := createMockGalasaSecret(secret1Name) + secret2 := createMockGalasaSecret(secret2Name) + formattableSecrets = append(formattableSecrets, secret1, secret2) + + // When... + actualFormattedOutput, err := formatter.FormatSecrets(formattableSecrets) + + // Then... + assert.Nil(t, err) + expectedSecret1Output := generateExpectedSecretYaml(secret1Name) + expectedSecret2Output := generateExpectedSecretYaml(secret2Name) + expectedFormattedOutput := fmt.Sprintf(`%s +--- +%s +`, expectedSecret1Output, expectedSecret2Output) + assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) +} \ No newline at end of file From 5265b63e5b7f7276e1e41979dd14be5516b6182b Mon Sep 17 00:00:00 2001 From: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:04:50 +0000 Subject: [PATCH 4/4] Add galasactl secrets set command (#302) * feat: Add galasactl secrets delete command Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * feat: Add galasactl secrets get command and formatters Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * feat: Add galasactl secrets set command Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * fix: Remove duplicate console output Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * docs: Add secrets commands to README Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * feat: Add description flag and validation to secrets Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * feat: Add last updated headers to secret summary output Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * fix: Allow secrets set to update description only Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * Empty commit to kick off build Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * fix: Move description to the last column in secrets get Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> * chore: Update secrets baseline Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --------- Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --- .secrets.baseline | 48 +- README.md | 56 + docs/generated/errors-list.md | 11 +- docs/generated/galasactl_secrets.md | 1 + docs/generated/galasactl_secrets_set.md | 39 + pkg/cmd/commandCollection.go | 7 + pkg/cmd/secretsSet.go | 205 +++ pkg/cmd/secretsSet_test.go | 146 +++ pkg/errors/errorMessage.go | 13 +- pkg/secrets/secrets.go | 12 +- pkg/secrets/secretsGet_test.go | 25 +- pkg/secrets/secretsSet.go | 223 ++++ pkg/secrets/secretsSet_test.go | 1147 +++++++++++++++++ pkg/secretsformatter/secretsFormatter.go | 2 + pkg/secretsformatter/summaryFormatter.go | 17 +- pkg/secretsformatter/summaryFormatter_test.go | 57 +- pkg/secretsformatter/yamlFormatter_test.go | 7 +- pkg/utils/httpInteractionMock.go | 9 + 18 files changed, 1976 insertions(+), 49 deletions(-) create mode 100644 docs/generated/galasactl_secrets_set.md create mode 100644 pkg/cmd/secretsSet.go create mode 100644 pkg/cmd/secretsSet_test.go create mode 100644 pkg/secrets/secretsSet.go create mode 100644 pkg/secrets/secretsSet_test.go diff --git a/.secrets.baseline b/.secrets.baseline index ff805898..f018cbc8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -151,7 +151,7 @@ "hashed_secret": "11747ed2a3904f82931baf592443772259ea8dc1", "is_secret": false, "is_verified": false, - "line_number": 25, + "line_number": 26, "type": "Secret Keyword", "verified_result": null }, @@ -159,7 +159,7 @@ "hashed_secret": "679d55ddc3c3d0f6ea2d11275a5d084669c98d56", "is_secret": false, "is_verified": false, - "line_number": 62, + "line_number": 67, "type": "Secret Keyword", "verified_result": null }, @@ -167,7 +167,7 @@ "hashed_secret": "3b938c1150a71e71e5f1ffeadbe6475f0f6a2e36", "is_secret": false, "is_verified": false, - "line_number": 122, + "line_number": 127, "type": "Secret Keyword", "verified_result": null }, @@ -175,7 +175,43 @@ "hashed_secret": "2dfbe3ec00a96d6f711d9a70f78be17f6fd574ca", "is_secret": false, "is_verified": false, - "line_number": 284, + "line_number": 289, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secrets/secretsSet.go": [ + { + "hashed_secret": "28aa91a8e751e5c49714ac040e98812f9110a1fd", + "is_secret": false, + "is_verified": false, + "line_number": 54, + "type": "Secret Keyword", + "verified_result": null + } + ], + "pkg/secrets/secretsSet_test.go": [ + { + "hashed_secret": "89e7fc0c50091804bfeb26cddefc0e701dd60fab", + "is_secret": false, + "is_verified": false, + "line_number": 316, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "edbd5e119f94badb9f99a67ac6ff4c7a5204ad61", + "is_secret": false, + "is_verified": false, + "line_number": 822, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "ea531d9e3ac1dc2beec9c298fb0026d59e4e2262", + "is_secret": false, + "is_verified": false, + "line_number": 825, "type": "Secret Keyword", "verified_result": null } @@ -195,7 +231,7 @@ "hashed_secret": "4d55af37dbbb6a42088d917caa1ca25428ec42c9", "is_secret": false, "is_verified": false, - "line_number": 44, + "line_number": 50, "type": "Secret Keyword", "verified_result": null } @@ -225,7 +261,7 @@ "hashed_secret": "679d55ddc3c3d0f6ea2d11275a5d084669c98d56", "is_secret": false, "is_verified": false, - "line_number": 29, + "line_number": 32, "type": "Secret Keyword", "verified_result": null } diff --git a/README.md b/README.md index 31172abd..158d45ef 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,62 @@ galasactl secrets get --name SYSTEM1 --format yaml For a complete list of supported parameters see [here](./docs/generated/galasactl_secrets_get.md). +## secrets set + +This command can be used to create and update secrets in the Galasa Ecosystem. These secrets can then be used in Galasa tests to authenticate with test systems and perform other secure operations. The name of a secret to create or update must be provided using the `--name` flag. + +### Examples + +The `--username`, `--password`, and `--token` flags can be used in different combinations to create different types of secret. + +For example, a UsernamePassword secret can be created by supplying `--username` and `--password`: + +``` +galasactl secrets set --name SYSTEM1 --username "my-username" --password "my-password" +``` + +A UsernameToken secret can be created by supplying `--username` and `--token`: + +``` +galasactl secrets set --name SYSTEM1 --username "my-username" --token "my-token" +``` + +A Token secret can be created by supplying `--token` on its own: +``` +galasactl secrets set --name SYSTEM1 --token "my-token" +``` + +A Username secret can be created by supplying `--username` on its own: + +``` +galasactl secrets set --name SYSTEM1 --username "my-username" +``` + +Base64-encoded credentials can be supplied using the `--base64-username`, `--base64-password`, and `--base64-token` flags. + +For example, to create a UsernamePassword secret where both the username and password are base64-encoded: + +``` +galasactl secrets set --name SYSTEM1 --base64-username "my-base64-username" --base64-password "my-base64-password" +``` + +It is also possible to mix these flags with their non-encoded variants discussed previously. For example, to create a UsernameToken secret where only the token is base64-encoded: + +``` +galasactl secrets set --name SYSTEM1 --username "my-base64-username" --base64-token "my-base64-token" +``` + +Once a secret has been created, you can change the type of the secret by supplying your desired secret type using the `--type` flag. When supplying the `--type` flag, all credentials for the new secret type must be provided. To find out what secret types are supported, run `galasactl secrets set --help`. + +For example, to create a UsernamePassword secret and then change it to a Token secret: + +``` +galasactl secrets set --name SYSTEM1 --username "my-username" --password "my-password" +galasactl secrets set --name SYSTEM1 --token "my-token" --type Token +``` + +For a complete list of supported parameters see [here](./docs/generated/galasactl_secrets_set.md). + ## secrets delete This command deletes a secret with the given name from the Galasa Ecosystem's credentials store. The name of the secret to be deleted must be provided using the `--name` flag. diff --git a/docs/generated/errors-list.md b/docs/generated/errors-list.md index 7aa345c8..e54c7486 100644 --- a/docs/generated/errors-list.md +++ b/docs/generated/errors-list.md @@ -167,7 +167,7 @@ The `galasactl` tool can generate the following errors: - GAL1169E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in a valid json format. Cause: '{}' - GAL1170E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are: '{}' - GAL1171E: An attempt to delete a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in the json format. -- GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty or contain spaces, and must only contain characters in the Latin-1 character set. +- GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty, contain spaces or dots (.), and must only contain characters in the Latin-1 character set. - GAL1173E: An attempt to delete a secret named '{}' failed. Sending the delete request to the Galasa service failed. Cause is {} - GAL1174E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. - GAL1175E: An attempt to get a secret named '{}' failed. Unexpected http status code {} received from the server. Error details from the server could not be read. Cause: {} @@ -181,6 +181,15 @@ The `galasactl` tool can generate the following errors: - GAL1183E: Failed to get secrets. Unexpected http status code {} received from the server. Error details from the server are: '{}' - GAL1184E: Failed to get secrets. Unexpected http status code {} received from the server. Error details from the server are not in the json format. - GAL1185E: Failed to get secrets. Sending the get request to the Galasa service failed. Cause is {} +- GAL1186E: Invalid secret type provided. Supported secret types are: {}. Check your provided command parameters and try again. +- GAL1187E: Failed to set a secret named '{}'. Unexpected http status code {} received from the server. +- GAL1188E: Failed to set a secret named '{}'. Unexpected http status code {} received from the server. Error details from the server could not be read. Cause: {} +- GAL1189E: Failed to set a secret named '{}'. Unexpected http status code {} received from the server. Error details from the server are not in a valid json format. Cause: '{}' +- GAL1190E: Failed to set a secret named '{}'. Unexpected http status code {} received from the server. Error details from the server are: '{}' +- GAL1191E: Failed to set a secret named '{}'. Unexpected http status code {} received from the server. Error details from the server are not in the json format. +- GAL1192E: Failed to set a secret named '{}'. Sending the put request to the Galasa service failed. Cause is {} +- GAL1193E: Invalid flag combination provided. --username cannot be provided with --base64-username, --password cannot be provided with --base64-password, and --token cannot be provided with --base64-token. Use the --help flag for more information, or refer to the documentation at https://galasa.dev/docs/reference/cli-commands. +- GAL1194E: Invalid secret description provided. The description provided with the --description flag cannot be an empty string, and must only contain characters in the Latin-1 character set. - GAL1225E: Failed to open file '{}' cause: {}. Check that this file exists, and that you have read permissions. - GAL1226E: Internal failure. Contents of gzip could be read, but not decoded. New gzip reader failed: file: {} error: {} - GAL1227E: Internal failure. Contents of gzip could not be decoded. {} error: {} diff --git a/docs/generated/galasactl_secrets.md b/docs/generated/galasactl_secrets.md index d5418926..f2d30acb 100644 --- a/docs/generated/galasactl_secrets.md +++ b/docs/generated/galasactl_secrets.md @@ -25,4 +25,5 @@ The parent command for operations to manipulate secrets in the Galasa service's * [galasactl](galasactl.md) - CLI for Galasa * [galasactl secrets delete](galasactl_secrets_delete.md) - Deletes a secret from the credentials store * [galasactl secrets get](galasactl_secrets_get.md) - Get secrets from the credentials store +* [galasactl secrets set](galasactl_secrets_set.md) - Creates or updates a secret in the credentials store diff --git a/docs/generated/galasactl_secrets_set.md b/docs/generated/galasactl_secrets_set.md new file mode 100644 index 00000000..5fee0ec9 --- /dev/null +++ b/docs/generated/galasactl_secrets_set.md @@ -0,0 +1,39 @@ +## galasactl secrets set + +Creates or updates a secret in the credentials store + +### Synopsis + +Creates or updates a secret in the credentials store + +``` +galasactl secrets set [flags] +``` + +### Options + +``` + --base64-password string a base64-encoded password to set into a secret + --base64-token string a base64-encoded token to set into a secret + --base64-username string a base64-encoded username to set into a secret + --description string the description to associate with the secret being created or updated + -h, --help Displays the options for the 'secrets set' command. + --name string A mandatory flag that identifies the secret to be created or manipulated. + --password string a password to set into a secret + --token string a token to set into a secret + --type string the desired secret type to convert an existing secret into. Supported types are: [UsernamePassword Username UsernameToken Token]. + --username string a username to set into a secret +``` + +### Options inherited from parent commands + +``` + -b, --bootstrap string Bootstrap URL. Should start with 'http://' or 'file://'. If it starts with neither, it is assumed to be a fully-qualified path. If missing, it defaults to use the 'bootstrap.properties' file in your GALASA_HOME. Example: http://example.com/bootstrap, file:///user/myuserid/.galasa/bootstrap.properties , file://C:/Users/myuserid/.galasa/bootstrap.properties + --galasahome string Path to a folder where Galasa will read and write files and configuration settings. The default is '${HOME}/.galasa'. This overrides the GALASA_HOME environment variable which may be set instead. + -l, --log string File to which log information will be sent. Any folder referred to must exist. An existing file will be overwritten. Specify "-" to log to stderr. Defaults to not logging. +``` + +### SEE ALSO + +* [galasactl secrets](galasactl_secrets.md) - Manage secrets stored in the Galasa service's credentials store + diff --git a/pkg/cmd/commandCollection.go b/pkg/cmd/commandCollection.go index 1d83c724..1ff65ec0 100644 --- a/pkg/cmd/commandCollection.go +++ b/pkg/cmd/commandCollection.go @@ -61,6 +61,7 @@ const ( COMMAND_NAME_RESOURCES_DELETE = "resources delete" COMMAND_NAME_SECRETS = "secrets" COMMAND_NAME_SECRETS_GET = "secrets get" + COMMAND_NAME_SECRETS_SET = "secrets set" COMMAND_NAME_SECRETS_DELETE = "secrets delete" COMMAND_NAME_USERS = "users" COMMAND_NAME_USERS_GET = "users get" @@ -387,6 +388,7 @@ func (commands *commandCollectionImpl) addSecretsCommands(factory spi.Factory, r var err error var secretsCommand spi.GalasaCommand var secretsGetCommand spi.GalasaCommand + var secretsSetCommand spi.GalasaCommand var secretsDeleteCommand spi.GalasaCommand secretsCommand, err = NewSecretsCmd(rootCommand) @@ -395,6 +397,10 @@ func (commands *commandCollectionImpl) addSecretsCommands(factory spi.Factory, r secretsGetCommand, err = NewSecretsGetCommand(factory, secretsCommand, rootCommand) } + if err == nil { + secretsSetCommand, err = NewSecretsSetCommand(factory, secretsCommand, rootCommand) + } + if err == nil { secretsDeleteCommand, err = NewSecretsDeleteCommand(factory, secretsCommand, rootCommand) } @@ -402,6 +408,7 @@ func (commands *commandCollectionImpl) addSecretsCommands(factory spi.Factory, r if err == nil { commands.commandMap[secretsCommand.Name()] = secretsCommand commands.commandMap[secretsGetCommand.Name()] = secretsGetCommand + commands.commandMap[secretsSetCommand.Name()] = secretsSetCommand commands.commandMap[secretsDeleteCommand.Name()] = secretsDeleteCommand } diff --git a/pkg/cmd/secretsSet.go b/pkg/cmd/secretsSet.go new file mode 100644 index 00000000..0b0f720a --- /dev/null +++ b/pkg/cmd/secretsSet.go @@ -0,0 +1,205 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "fmt" + "log" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/secrets" + "github.com/galasa-dev/cli/pkg/spi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/spf13/cobra" +) + +type SecretsSetCmdValues struct { + secretType string + base64Username string + base64Password string + base64Token string + username string + password string + token string + description string +} + +type SecretsSetCommand struct { + values *SecretsSetCmdValues + cobraCommand *cobra.Command +} + +// ------------------------------------------------------------------------------------------------ +// Constructors methods +// ------------------------------------------------------------------------------------------------ +func NewSecretsSetCommand( + factory spi.Factory, + secretsSetCommand spi.GalasaCommand, + rootCmd spi.GalasaCommand, +) (spi.GalasaCommand, error) { + + cmd := new(SecretsSetCommand) + + err := cmd.init(factory, secretsSetCommand, rootCmd) + return cmd, err +} + +// ------------------------------------------------------------------------------------------------ +// Public methods +// ------------------------------------------------------------------------------------------------ +func (cmd *SecretsSetCommand) Name() string { + return COMMAND_NAME_SECRETS_SET +} + +func (cmd *SecretsSetCommand) CobraCommand() *cobra.Command { + return cmd.cobraCommand +} + +func (cmd *SecretsSetCommand) Values() interface{} { + return cmd.values +} + +// ------------------------------------------------------------------------------------------------ +// Private methods +// ------------------------------------------------------------------------------------------------ +func (cmd *SecretsSetCommand) init(factory spi.Factory, secretsCommand spi.GalasaCommand, rootCmd spi.GalasaCommand) error { + var err error + + cmd.values = &SecretsSetCmdValues{} + cmd.cobraCommand, err = cmd.createCobraCmd(factory, secretsCommand, rootCmd.Values().(*RootCmdValues)) + + return err +} + +func (cmd *SecretsSetCommand) createCobraCmd( + factory spi.Factory, + secretsCommand spi.GalasaCommand, + rootCommandValues *RootCmdValues, +) (*cobra.Command, error) { + + var err error + + secretsCommandValues := secretsCommand.Values().(*SecretsCmdValues) + secretsSetCobraCmd := &cobra.Command{ + Use: "set", + Short: "Creates or updates a secret in the credentials store", + Long: "Creates or updates a secret in the credentials store", + Aliases: []string{COMMAND_NAME_SECRETS_SET}, + RunE: func(cobraCommand *cobra.Command, args []string) error { + return cmd.executeSecretsSet(factory, secretsCommand.Values().(*SecretsCmdValues), rootCommandValues) + }, + } + + addSecretNameFlag(secretsSetCobraCmd, true, secretsCommandValues) + + usernameFlag := "username" + passwordFlag := "password" + tokenFlag := "token" + + base64UsernameFlag := "base64-username" + base64PasswordFlag := "base64-password" + base64TokenFlag := "base64-token" + + descriptionFlag := "description" + + secretsSetCobraCmd.Flags().StringVar(&cmd.values.secretType, "type", "", fmt.Sprintf("the desired secret type to convert an existing secret into. Supported types are: %v.", galasaapi.AllowedGalasaSecretTypeEnumValues)) + secretsSetCobraCmd.Flags().StringVar(&cmd.values.description, descriptionFlag, "", "the description to associate with the secret being created or updated") + secretsSetCobraCmd.Flags().StringVar(&cmd.values.username, usernameFlag, "", "a username to set into a secret") + secretsSetCobraCmd.Flags().StringVar(&cmd.values.password, passwordFlag, "", "a password to set into a secret") + secretsSetCobraCmd.Flags().StringVar(&cmd.values.token, tokenFlag, "", "a token to set into a secret") + + secretsSetCobraCmd.Flags().StringVar(&cmd.values.base64Username, base64UsernameFlag, "", "a base64-encoded username to set into a secret") + secretsSetCobraCmd.Flags().StringVar(&cmd.values.base64Password, base64PasswordFlag, "", "a base64-encoded password to set into a secret") + secretsSetCobraCmd.Flags().StringVar(&cmd.values.base64Token, base64TokenFlag, "", "a base64-encoded token to set into a secret") + + // A non-encoded credential cannot be provided alongside an encoded credential + secretsSetCobraCmd.MarkFlagsMutuallyExclusive(usernameFlag, base64UsernameFlag) + + // A password cannot be provided alongside a token (there is no secret type that allows both) + secretsSetCobraCmd.MarkFlagsMutuallyExclusive(passwordFlag, tokenFlag, base64PasswordFlag, base64TokenFlag) + + // A secret must have a name and at least one of the credentials flags + secretsSetCobraCmd.MarkFlagsOneRequired( + usernameFlag, + passwordFlag, + tokenFlag, + base64UsernameFlag, + base64PasswordFlag, + base64TokenFlag, + descriptionFlag, + ) + + secretsCommand.CobraCommand().AddCommand(secretsSetCobraCmd) + + return secretsSetCobraCmd, err +} + +func (cmd *SecretsSetCommand) executeSecretsSet( + factory spi.Factory, + secretsCmdValues *SecretsCmdValues, + rootCmdValues *RootCmdValues, +) error { + + var err error + // Operations on the file system will all be relative to the current folder. + fileSystem := factory.GetFileSystem() + + err = utils.CaptureLog(fileSystem, rootCmdValues.logFileName) + + if err == nil { + rootCmdValues.isCapturingLogs = true + + log.Println("Galasa CLI - Set secrets from the ecosystem") + + env := factory.GetEnvironment() + + var galasaHome spi.GalasaHome + galasaHome, err = utils.NewGalasaHome(fileSystem, env, rootCmdValues.CmdParamGalasaHomePath) + if err == nil { + + var urlService *api.RealUrlResolutionService = new(api.RealUrlResolutionService) + var bootstrapData *api.BootstrapData + bootstrapData, err = api.LoadBootstrap(galasaHome, fileSystem, env, secretsCmdValues.bootstrap, urlService) + if err == nil { + + var console = factory.GetStdOutConsole() + + apiServerUrl := bootstrapData.ApiServerURL + log.Printf("The API server is at '%s'\n", apiServerUrl) + + authenticator := factory.GetAuthenticator( + apiServerUrl, + galasaHome, + ) + + var apiClient *galasaapi.APIClient + apiClient, err = authenticator.GetAuthenticatedAPIClient() + + byteReader := factory.GetByteReader() + + if err == nil { + err = secrets.SetSecret( + secretsCmdValues.name, + cmd.values.username, + cmd.values.password, + cmd.values.token, + cmd.values.base64Username, + cmd.values.base64Password, + cmd.values.base64Token, + cmd.values.secretType, + cmd.values.description, + console, + apiClient, + byteReader, + ) + } + } + } + } + + return err +} diff --git a/pkg/cmd/secretsSet_test.go b/pkg/cmd/secretsSet_test.go new file mode 100644 index 00000000..2731846e --- /dev/null +++ b/pkg/cmd/secretsSet_test.go @@ -0,0 +1,146 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "testing" + + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestCommandListContainsSecretsSetCommand(t *testing.T) { + /// Given... + factory := utils.NewMockFactory() + commands, _ := NewCommandCollection(factory) + + // When... + secretsCommand, err := commands.GetCommand(COMMAND_NAME_SECRETS_SET) + assert.Nil(t, err) + + // Then... + assert.NotNil(t, secretsCommand) + assert.Equal(t, COMMAND_NAME_SECRETS_SET, secretsCommand.Name()) + assert.NotNil(t, secretsCommand.Values()) + assert.IsType(t, &SecretsSetCmdValues{}, secretsCommand.Values()) +} + +func TestSecretsSetHelpFlagSetCorrectly(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + + var args []string = []string{"secrets", "set", "--help"} + + // When... + err := Execute(factory, args) + + // Then... + checkOutput("Creates or updates a secret in the credentials store", "", factory, t) + + assert.Nil(t, err) +} + +func TestSecretsSetNoNameFlagProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "set"} + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", `Error: required flag(s) "name" not set`, factory, t) +} + +func TestSecretsSetNonEncodedUsernameFlagWithEncodedFlagProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "set", + "--name", "SYSTEM1", + "--username", "myuser", + "--base64-username", "mybase64user", + } + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", "Error: if any flags in the group [username base64-username] are set none of the others can be", factory, t) +} + +func TestSecretsSetNonEncodedPasswordFlagWithEncodedFlagProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "set", + "--name", "SYSTEM1", + "--username", "myuser", + "--password", "mypassword", + "--base64-password", "my-base64-password", + } + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", "Error: if any flags in the group [password token base64-password base64-token] are set none of the others can be", factory, t) +} + +func TestSecretsSetNonEncodedTokenFlagWithEncodedFlagProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "set", + "--name", "SYSTEM1", + "--username", "myuser", + "--token", "mytoken", + "--base64-token", "my-base64-token", + } + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", "Error: if any flags in the group [password token base64-password base64-token] are set none of the others can be", factory, t) +} + +func TestSecretsSetPasswordAndTokenFlagsProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "set", + "--name", "SYSTEM1", + "--password", "mypassword", + "--token", "mytoken", + } + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", "Error: if any flags in the group [password token base64-password base64-token] are set none of the others can be", factory, t) +} + +func TestSecretsSetWithOnlyNameFlagProducesErrorMessage(t *testing.T) { + // Given... + factory := utils.NewMockFactory() + var args []string = []string{"secrets", "set", "--name", "SYSTEM1" } + + // When... + err := Execute(factory, args) + + // Then... + assert.NotNil(t, err) + + checkOutput("", "Error: at least one of the flags in the group [username password token base64-username base64-password base64-token description] is required", factory, t) +} diff --git a/pkg/errors/errorMessage.go b/pkg/errors/errorMessage.go index 5c586d18..705eecf3 100644 --- a/pkg/errors/errorMessage.go +++ b/pkg/errors/errorMessage.go @@ -268,7 +268,7 @@ var ( GALASA_ERROR_DELETE_SECRET_UNPARSEABLE_CONTENT = NewMessageType("GAL1169E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1169, STACK_TRACE_NOT_WANTED) GALASA_ERROR_DELETE_SECRET_SERVER_REPORTED_ERROR = NewMessageType("GAL1170E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1170, STACK_TRACE_NOT_WANTED) GALASA_ERROR_DELETE_SECRET_EXPLANATION_NOT_JSON = NewMessageType("GAL1171E: An attempt to delete a secret named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1171, STACK_TRACE_NOT_WANTED) - GALASA_ERROR_INVALID_SECRET_NAME = NewMessageType("GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty or contain spaces, and must only contain characters in the Latin-1 character set.", 1172, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_INVALID_SECRET_NAME = NewMessageType("GAL1172E: Invalid secret name provided. The name provided with the --name flag cannot be empty, contain spaces or dots (.), and must only contain characters in the Latin-1 character set.", 1172, STACK_TRACE_NOT_WANTED) GALASA_ERROR_DELETE_SECRET_REQUEST_FAILED = NewMessageType("GAL1173E: An attempt to delete a secret named '%s' failed. Sending the delete request to the Galasa service failed. Cause is %v", 1173, STACK_TRACE_NOT_WANTED) GALASA_ERROR_GET_SECRET_NO_RESPONSE_CONTENT = NewMessageType("GAL1174E: An attempt to get a secret named '%s' failed. Unexpected http status code %v received from the server.", 1174, STACK_TRACE_NOT_WANTED) @@ -284,6 +284,17 @@ var ( GALASA_ERROR_GET_SECRETS_SERVER_REPORTED_ERROR = NewMessageType("GAL1183E: Failed to get secrets. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1183, STACK_TRACE_NOT_WANTED) GALASA_ERROR_GET_SECRETS_EXPLANATION_NOT_JSON = NewMessageType("GAL1184E: Failed to get secrets. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1184, STACK_TRACE_NOT_WANTED) GALASA_ERROR_GET_SECRETS_REQUEST_FAILED = NewMessageType("GAL1185E: Failed to get secrets. Sending the get request to the Galasa service failed. Cause is %v", 1185, STACK_TRACE_NOT_WANTED) + + GALASA_ERROR_INVALID_SECRET_TYPE_PROVIDED = NewMessageType("GAL1186E: Invalid secret type provided. Supported secret types are: %v. Check your provided command parameters and try again.", 1186, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_NO_RESPONSE_CONTENT = NewMessageType("GAL1187E: Failed to set a secret named '%s'. Unexpected http status code %v received from the server.", 1187, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_RESPONSE_BODY_UNREADABLE = NewMessageType("GAL1188E: Failed to set a secret named '%s'. Unexpected http status code %v received from the server. Error details from the server could not be read. Cause: %s", 1188, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_UNPARSEABLE_CONTENT = NewMessageType("GAL1189E: Failed to set a secret named '%s'. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1189, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_SERVER_REPORTED_ERROR = NewMessageType("GAL1190E: Failed to set a secret named '%s'. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1190, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_EXPLANATION_NOT_JSON = NewMessageType("GAL1191E: Failed to set a secret named '%s'. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1191, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_REQUEST_FAILED = NewMessageType("GAL1192E: Failed to set a secret named '%s'. Sending the put request to the Galasa service failed. Cause is %v", 1192, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SET_SECRET_INVALID_FLAG_COMBINATION = NewMessageType("GAL1193E: Invalid flag combination provided. --username cannot be provided with --base64-username, --password cannot be provided with --base64-password, and --token cannot be provided with --base64-token."+SEE_COMMAND_REFERENCE, 1193, STACK_TRACE_NOT_WANTED) + + GALASA_ERROR_INVALID_SECRET_DESCRIPTION = NewMessageType("GAL1194E: Invalid secret description provided. The description provided with the --description flag cannot be an empty string, and must only contain characters in the Latin-1 character set.", 1194, STACK_TRACE_NOT_WANTED) // Warnings... GALASA_WARNING_MAVEN_NO_GALASA_OBR_REPO = NewMessageType("GAL2000W: Warning: Maven configuration file settings.xml should contain a reference to a Galasa repository so that the galasa OBR can be resolved. The official release repository is '%s', and 'pre-release' repository is '%s'", 2000, STACK_TRACE_WANTED) diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index e73c5ebe..8a4374d8 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -16,12 +16,22 @@ func validateSecretName(secretName string) (string, error) { var err error secretName = strings.TrimSpace(secretName) - if secretName == "" || strings.ContainsAny(secretName, " \n\t") || !isLatin1(secretName) { + if secretName == "" || strings.ContainsAny(secretName, " .\n\t") || !isLatin1(secretName) { err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_INVALID_SECRET_NAME) } return secretName, err } +func validateDescription(description string) (string, error) { + var err error + description = strings.TrimSpace(description) + + if description == "" || !isLatin1(description) { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_INVALID_SECRET_DESCRIPTION) + } + return description, err +} + // Checks if a given string contains only characters in the Latin-1 character set (codepoints 0-255), // returning true if so, and false otherwise func isLatin1(str string) bool { diff --git a/pkg/secrets/secretsGet_test.go b/pkg/secrets/secretsGet_test.go index de3310e6..8ce3ec11 100644 --- a/pkg/secrets/secretsGet_test.go +++ b/pkg/secrets/secretsGet_test.go @@ -11,6 +11,7 @@ import ( "net/http" "strconv" "testing" + "time" "github.com/galasa-dev/cli/pkg/api" "github.com/galasa-dev/cli/pkg/galasaapi" @@ -35,6 +36,8 @@ func createMockGalasaSecret(secretName string, description string) galasaapi.Gal secretMetadata.SetName(secretName) secretMetadata.SetEncoding(DUMMY_ENCODING) secretMetadata.SetType("UsernamePassword") + secretMetadata.SetLastUpdatedBy(DUMMY_USERNAME) + secretMetadata.SetLastUpdatedTime(time.Date(2024, 01, 01, 10, 0, 0, 0, time.UTC)) if description != "" { secretMetadata.SetDescription(description) @@ -55,11 +58,13 @@ kind: GalasaSecret metadata: name: %s description: %s + lastUpdatedTime: 2024-01-01T10:00:00Z + lastUpdatedBy: %s encoding: %s type: UsernamePassword data: username: %s - password: %s`, API_VERSION, secretName, description, DUMMY_ENCODING, DUMMY_USERNAME, DUMMY_PASSWORD) + password: %s`, API_VERSION, secretName, description, DUMMY_USERNAME, DUMMY_ENCODING, DUMMY_USERNAME, DUMMY_PASSWORD) } func TestCanGetASecretByName(t *testing.T) { @@ -102,12 +107,12 @@ func TestCanGetASecretByName(t *testing.T) { mockByteReader) // Then... - expectedOutput := fmt.Sprintf( -`name type description -%s UsernamePassword %s + expectedOutput := +`name type last-updated(UTC) last-updated-by description +SYSTEM1 UsernamePassword 2024-01-01 10:00:00 dummy-username my SYSTEM1 secret Total:1 -`, secretName, description) +` assert.Nil(t, err, "GetSecrets returned an unexpected error") assert.Equal(t, expectedOutput, console.ReadText()) } @@ -205,13 +210,13 @@ func TestCanGetAllSecretsOk(t *testing.T) { mockByteReader) // Then... - expectedOutput := fmt.Sprintf( -`name type description -%s UsernamePassword %s -%s UsernamePassword %s + expectedOutput := +`name type last-updated(UTC) last-updated-by description +BOB UsernamePassword 2024-01-01 10:00:00 dummy-username my BOB secret +BLAH UsernamePassword 2024-01-01 10:00:00 dummy-username my BLAH secret Total:2 -`, secret1Name, description1, secret2Name, description2) +` assert.Nil(t, err, "GetSecrets returned an unexpected error") assert.Equal(t, expectedOutput, console.ReadText()) } diff --git a/pkg/secrets/secretsSet.go b/pkg/secrets/secretsSet.go new file mode 100644 index 00000000..f5865cba --- /dev/null +++ b/pkg/secrets/secretsSet.go @@ -0,0 +1,223 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package secrets + +import ( + "context" + "log" + "net/http" + "strings" + + "github.com/galasa-dev/cli/pkg/embedded" + galasaErrors "github.com/galasa-dev/cli/pkg/errors" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/spi" +) + +const ( + BASE64_ENCODING = "base64" +) + +// Creates or updates a Galasa Secret using the provided parameters into an ecosystem's credentials store +func SetSecret( + secretName string, + username string, + password string, + token string, + base64Username string, + base64Password string, + base64Token string, + secretType string, + description string, + console spi.Console, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) error { + var err error + + secretName, err = validateSecretName(secretName) + if err == nil { + log.Printf("Secret name validated OK") + if description != "" { + description, err = validateDescription(description) + } + + if err == nil { + err = validateFlagCombination(username, password, token, base64Username, base64Password, base64Token) + + if err == nil { + requestUsername := createSecretRequestUsername(username, base64Username) + requestPassword := createSecretRequestPassword(password, base64Password) + requestToken := createSecretRequestToken(token, base64Token) + + var secretTypeValue galasaapi.NullableGalasaSecretType + if secretType != "" { + secretTypeValue, err = validateSecretType(secretType) + } + + if err == nil { + secretRequest := createSecretRequest(secretName, requestUsername, requestPassword, requestToken, secretTypeValue, description) + err = sendSetSecretRequest(secretRequest, apiClient, byteReader) + } + } + } + } + log.Printf("SecretsSet exiting. err is %v\n", err) + return err +} + +func createSecretRequestUsername(username string, base64Username string) galasaapi.SecretRequestUsername { + requestUsername := *galasaapi.NewSecretRequestUsername() + + username = strings.TrimSpace(username) + base64Username = strings.TrimSpace(base64Username) + + if base64Username != "" { + requestUsername.SetValue(base64Username) + requestUsername.SetEncoding(BASE64_ENCODING) + } else if username != "" { + requestUsername.SetValue(username) + } + return requestUsername +} + +func createSecretRequestPassword(password string, base64Password string) galasaapi.SecretRequestPassword { + requestPassword := *galasaapi.NewSecretRequestPassword() + + if base64Password != "" { + requestPassword.SetValue(base64Password) + requestPassword.SetEncoding(BASE64_ENCODING) + } else if password != "" { + requestPassword.SetValue(password) + } + return requestPassword +} + +func createSecretRequestToken(token string, base64Token string) galasaapi.SecretRequestToken { + requestToken := *galasaapi.NewSecretRequestToken() + + if base64Token != "" { + requestToken.SetValue(base64Token) + requestToken.SetEncoding(BASE64_ENCODING) + } else if token != "" { + requestToken.SetValue(token) + } + return requestToken +} + +func createSecretRequest( + secretName string, + username galasaapi.SecretRequestUsername, + password galasaapi.SecretRequestPassword, + token galasaapi.SecretRequestToken, + secretType galasaapi.NullableGalasaSecretType, + description string, +) *galasaapi.SecretRequest { + secretRequest := galasaapi.NewSecretRequest() + secretRequest.SetName(secretName) + + if description != "" { + secretRequest.SetDescription(description) + } + + if secretType.IsSet() { + secretRequest.SetType(*secretType.Get()) + } + + if username.GetValue() != "" { + secretRequest.SetUsername(username) + } + + if password.GetValue() != "" { + secretRequest.SetPassword(password) + } + + if token.GetValue() != "" { + secretRequest.SetToken(token) + } + return secretRequest +} + +func sendSetSecretRequest( + secretRequest *galasaapi.SecretRequest, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) error { + var err error + var httpResponse *http.Response + var context context.Context = context.Background() + var restApiVersion string + + restApiVersion, err = embedded.GetGalasactlRestApiVersion() + secretName := secretRequest.GetName() + + if err == nil { + httpResponse, err = apiClient.SecretsAPIApi.UpdateSecret(context, secretName). + ClientApiVersion(restApiVersion). + SecretRequest(*secretRequest). + Execute() + + if httpResponse != nil { + defer httpResponse.Body.Close() + } + + if err != nil { + if httpResponse == nil { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_SET_SECRET_REQUEST_FAILED, err.Error()) + } else { + err = galasaErrors.HttpResponseToGalasaError( + httpResponse, + secretName, + byteReader, + galasaErrors.GALASA_ERROR_SET_SECRET_NO_RESPONSE_CONTENT, + galasaErrors.GALASA_ERROR_SET_SECRET_RESPONSE_BODY_UNREADABLE, + galasaErrors.GALASA_ERROR_SET_SECRET_UNPARSEABLE_CONTENT, + galasaErrors.GALASA_ERROR_SET_SECRET_SERVER_REPORTED_ERROR, + galasaErrors.GALASA_ERROR_SET_SECRET_EXPLANATION_NOT_JSON, + ) + } + } + } + return err +} + +func validateSecretType(secretType string) (galasaapi.NullableGalasaSecretType, error) { + var err error + var nullableSecretType galasaapi.NullableGalasaSecretType + secretType = strings.TrimSpace(secretType) + + // Try to convert the provided type into a GalasaSecretType value + for _, supportedType := range galasaapi.AllowedGalasaSecretTypeEnumValues { + if strings.EqualFold(secretType, string(supportedType)) { + nullableSecretType = *galasaapi.NewNullableGalasaSecretType(&supportedType) + break + } + } + if !nullableSecretType.IsSet() { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_INVALID_SECRET_TYPE_PROVIDED, galasaapi.AllowedGalasaSecretTypeEnumValues) + } + return nullableSecretType, err +} + +func validateFlagCombination( + username string, + password string, + token string, + base64Username string, + base64Password string, + base64Token string, +) error { + var err error + + // Make sure that a field and its base64 equivalent haven't both been provided + if (username != "" && base64Username != "") || + (password != "" && base64Password != "") || + (token != "" && base64Token != "") { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_SET_SECRET_INVALID_FLAG_COMBINATION) + } + return err +} \ No newline at end of file diff --git a/pkg/secrets/secretsSet_test.go b/pkg/secrets/secretsSet_test.go new file mode 100644 index 00000000..1359c544 --- /dev/null +++ b/pkg/secrets/secretsSet_test.go @@ -0,0 +1,1147 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package secrets + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "testing" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/errors" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func readSecretRequestBody(req *http.Request) galasaapi.SecretRequest { + var secretRequest galasaapi.SecretRequest + requestBodyBytes, _ := io.ReadAll(req.Body) + defer req.Body.Close() + + _ = json.Unmarshal(requestBodyBytes, &secretRequest) + return secretRequest +} + +func TestCanCreateAUsernameSecret(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "my-username" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + createSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + createSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + + requestUsername := secretRequest.GetUsername() + assert.Equal(t, requestUsername.GetValue(), username) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Empty(t, requestPassword.GetValue()) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Empty(t, requestToken.GetValue()) + assert.Empty(t, requestToken.GetEncoding()) + } + + createSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusCreated) + } + + interactions := []utils.HttpInteraction{ + createSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanCreateAUsernamePasswordSecret(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "my-username" + password := "my-password" + token := "" + base64Username := "" + base64Password := "" + base64Token := "" + secretType := "" + description := "my secret description" + + // Create the expected HTTP interactions with the API server + createSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + createSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + assert.Equal(t, secretRequest.GetDescription(), description) + + requestUsername := secretRequest.GetUsername() + assert.Equal(t, requestUsername.GetValue(), username) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Equal(t, requestPassword.GetValue(), password) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Empty(t, requestToken.GetValue()) + assert.Empty(t, requestToken.GetEncoding()) + } + + createSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusCreated) + } + + interactions := []utils.HttpInteraction{ + createSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanCreateAUsernameTokenSecret(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "my-username" + password := "" + token := "my-token" + base64Username := "" + base64Password := "" + base64Token := "" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + createSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + createSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + + requestUsername := secretRequest.GetUsername() + assert.Equal(t, requestUsername.GetValue(), username) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Empty(t, requestPassword.GetValue()) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Equal(t, requestToken.GetValue(), token) + assert.Empty(t, requestToken.GetEncoding()) + } + + createSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusCreated) + } + + interactions := []utils.HttpInteraction{ + createSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanCreateATokenSecret(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "" + password := "" + token := "my-token" + base64Username := "" + base64Password := "" + base64Token := "" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + createSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + createSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + + requestUsername := secretRequest.GetUsername() + assert.Empty(t, requestUsername.GetValue()) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Empty(t, requestPassword.GetValue()) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Equal(t, requestToken.GetValue(), token) + assert.Empty(t, requestToken.GetEncoding()) + } + + createSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusCreated) + } + + interactions := []utils.HttpInteraction{ + createSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanUpdateASecret(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "" + password := "my-new-password" + token := "" + base64Username := "" + base64Password := "" + base64Token := "" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + updateSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + + requestUsername := secretRequest.GetUsername() + assert.Empty(t, requestUsername.GetValue()) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Equal(t, requestPassword.GetValue(), password) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Empty(t, requestToken.GetValue()) + assert.Empty(t, requestToken.GetEncoding()) + } + + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanUpdateAUsernamePasswordSecretInBase64Format(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "" + password := "" + token := "" + base64Username := "my-base64-username" + base64Password := "my-base64-password" + base64Token := "" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + updateSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + + requestUsername := secretRequest.GetUsername() + assert.Equal(t, requestUsername.GetValue(), base64Username) + assert.Equal(t, requestUsername.GetEncoding(), BASE64_ENCODING) + + requestPassword := secretRequest.GetPassword() + assert.Equal(t, requestPassword.GetValue(), base64Password) + assert.Equal(t, requestPassword.GetEncoding(), BASE64_ENCODING) + + requestToken := secretRequest.GetToken() + assert.Empty(t, requestToken.GetValue()) + assert.Empty(t, requestToken.GetEncoding()) + } + + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanUpdateATokenSecretInBase64Format(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + updateSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Empty(t, secretRequest.GetType()) + + requestUsername := secretRequest.GetUsername() + assert.Empty(t, requestUsername.GetValue()) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Empty(t, requestPassword.GetValue()) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Equal(t, requestToken.GetValue(), base64Token) + assert.Equal(t, requestToken.GetEncoding(), BASE64_ENCODING) + } + + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestCanUpdateASecretsTypeOk(t *testing.T) { + // Given... + secretName := "SYSTEM1" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "token" + description := "my new token" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + + // Validate the request body + updateSecretInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + secretRequest := readSecretRequestBody(req) + assert.Equal(t, secretRequest.GetName(), secretName) + assert.Equal(t, secretRequest.GetType(), galasaapi.TOKEN) + + requestUsername := secretRequest.GetUsername() + assert.Empty(t, requestUsername.GetValue()) + assert.Empty(t, requestUsername.GetEncoding()) + + requestPassword := secretRequest.GetPassword() + assert.Empty(t, requestPassword.GetValue()) + assert.Empty(t, requestPassword.GetEncoding()) + + requestToken := secretRequest.GetToken() + assert.Equal(t, requestToken.GetValue(), base64Token) + assert.Equal(t, requestToken.GetEncoding(), BASE64_ENCODING) + } + + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.Nil(t, err, "SetSecret returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful creation, it should be empty") +} + +func TestUpdateSecretWithNoNameThrowsError(t *testing.T) { + // Given... + secretName := "" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Validation should fail, so no HTTP interactions should take place + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1172E") + assert.Contains(t, errorMsg, "Invalid secret name provided") +} + +func TestUpdateSecretWithNonLatin1NameThrowsError(t *testing.T) { + // Given... + secretName := string(rune(300)) + "NONLATIN1" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Validation should fail, so no HTTP interactions should take place + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1172E") + assert.Contains(t, errorMsg, "Invalid secret name provided") +} + +func TestUpdateSecretWithNonLatin1DescriptionThrowsError(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := string(rune(256)) + " is not latin-1" + + // Validation should fail, so no HTTP interactions should take place + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1194E") + assert.Contains(t, errorMsg, "Invalid secret description provided") +} + +func TestUpdateSecretWithBlankDescriptionThrowsError(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := " " + + // Validation should fail, so no HTTP interactions should take place + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1194E") + assert.Contains(t, errorMsg, "Invalid secret description provided") +} + +func TestUpdateSecretWithUnknownTypeThrowsError(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "UNKNOWN" + description := "this should fail!" + + // Validation should fail, so no HTTP interactions should take place + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1186E") + assert.Contains(t, errorMsg, "Invalid secret type provided") +} + +func TestUpdateSecretWithInvalidFlagCombinationThrowsError(t *testing.T) { + // Given... + // Provide a unencoded credentials and base64-encoded ones + secretName := "MYSECRET" + username := "my-username" + password := "my-password" + token := "my-token" + base64Username := "my-base64-username" + base64Password := "my-base64-password" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Validation should fail, so no HTTP interactions should take place + interactions := []utils.HttpInteraction{} + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error as expected") + errorMsg := err.Error() + assert.Contains(t, errorMsg, "GAL1193E") + assert.Contains(t, errorMsg, "Invalid flag combination provided") +} + +func TestSetSecretFailsWithNoExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText , secretName) + assert.Contains(t, consoleText , "GAL1187E") + assert.Contains(t, consoleText , "Unexpected http status code 500 received from the server") +} + +func TestSetSecretFailsWithNonJsonContentTypeExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + writer.Header().Set("Content-Type", "application/notJsonOnPurpose") + writer.Write([]byte("something not json but non-zero-length.")) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1191E") + assert.Contains(t, consoleText, "Error details from the server are not in the json format") +} + +func TestSetSecretFailsWithBadlyFormedJsonContentExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{ "this": "isBadJson because it doesnt end in a close braces" `)) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1189E") + assert.Contains(t, consoleText, "Error details from the server are not in a valid json format") + assert.Contains(t, consoleText, "Cause: 'unexpected end of JSON input'") +} + +func TestSetSecretFailsWithValidErrorResponsePayloadGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + apiErrorCode := 5000 + apiErrorMessage := "this is an error from the API server" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + + apiError := errors.GalasaAPIError{ + Code: apiErrorCode, + Message: apiErrorMessage, + } + apiErrorBytes, _ := json.Marshal(apiError) + writer.Write(apiErrorBytes) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReader() + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1190E") + assert.Contains(t, consoleText, apiErrorMessage) +} + +func TestSecretsSetFailsWithFailureToReadResponseBodyGivesCorrectMessage(t *testing.T) { + // Given... + secretName := "MYSECRET" + username := "" + password := "" + token := "" + base64Username := "" + base64Password := "" + base64Token := "my-base64-token" + secretType := "" + description := "" + + // Create the expected HTTP interactions with the API server + updateSecretInteraction := utils.NewHttpInteraction("/secrets/" + secretName, http.MethodPut) + updateSecretInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{}`)) + } + + interactions := []utils.HttpInteraction{ + updateSecretInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockByteReader := utils.NewMockByteReaderAsMock(true) + + // When... + err := SetSecret( + secretName, + username, + password, + token, + base64Username, + base64Password, + base64Token, + secretType, + description, + console, + apiClient, + mockByteReader) + + // Then... + assert.NotNil(t, err, "SetSecret did not return an error but it should have") + consoleText := err.Error() + assert.Contains(t, consoleText, secretName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1188E") + assert.Contains(t, consoleText, "Error details from the server could not be read") +} diff --git a/pkg/secretsformatter/secretsFormatter.go b/pkg/secretsformatter/secretsFormatter.go index acdf2a7c..878600bd 100644 --- a/pkg/secretsformatter/secretsFormatter.go +++ b/pkg/secretsformatter/secretsFormatter.go @@ -27,6 +27,8 @@ const ( HEADER_SECRET_NAME = "name" HEADER_SECRET_TYPE = "type" HEADER_SECRET_DESCRIPTION = "description" + HEADER_LAST_UPDATED_TIME = "last-updated(UTC)" + HEADER_LAST_UPDATED_BY = "last-updated-by" ) type SecretsFormatter interface { diff --git a/pkg/secretsformatter/summaryFormatter.go b/pkg/secretsformatter/summaryFormatter.go index 1c5ca849..23437731 100644 --- a/pkg/secretsformatter/summaryFormatter.go +++ b/pkg/secretsformatter/summaryFormatter.go @@ -38,7 +38,13 @@ func (*SecretSummaryFormatter) FormatSecrets(secrets []galasaapi.GalasaSecret) ( if totalSecrets > 0 { var table [][]string - var headers = []string{ HEADER_SECRET_NAME, HEADER_SECRET_TYPE, HEADER_SECRET_DESCRIPTION } + var headers = []string{ + HEADER_SECRET_NAME, + HEADER_SECRET_TYPE, + HEADER_LAST_UPDATED_TIME, + HEADER_LAST_UPDATED_BY, + HEADER_SECRET_DESCRIPTION, + } table = append(table, headers) for _, secret := range secrets { @@ -46,8 +52,15 @@ func (*SecretSummaryFormatter) FormatSecrets(secrets []galasaapi.GalasaSecret) ( name := secret.Metadata.GetName() secretType := secret.Metadata.GetType() secretDescription := secret.Metadata.GetDescription() + lastUpdatedTime := secret.Metadata.GetLastUpdatedTime() + + lastUpdatedTimeReadable := "" + if !lastUpdatedTime.IsZero() { + lastUpdatedTimeReadable = lastUpdatedTime.Format("2006-01-02 15:04:05") + } + lastUpdatedBy := secret.Metadata.GetLastUpdatedBy() - line = append(line, name, string(secretType), secretDescription) + line = append(line, name, string(secretType), lastUpdatedTimeReadable, lastUpdatedBy, secretDescription) table = append(table, line) } diff --git a/pkg/secretsformatter/summaryFormatter_test.go b/pkg/secretsformatter/summaryFormatter_test.go index 7324a640..6debbe8f 100644 --- a/pkg/secretsformatter/summaryFormatter_test.go +++ b/pkg/secretsformatter/summaryFormatter_test.go @@ -6,11 +6,11 @@ package secretsformatter import ( - "fmt" - "testing" + "testing" + "time" - "github.com/galasa-dev/cli/pkg/galasaapi" - "github.com/stretchr/testify/assert" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/stretchr/testify/assert" ) const ( @@ -20,7 +20,10 @@ const ( DUMMY_PASSWORD = "dummy-password" ) -func createMockGalasaSecretWithDescription(secretName string, description string) galasaapi.GalasaSecret { +func createMockGalasaSecretWithDescription( + secretName string, + description string, +) galasaapi.GalasaSecret { secret := *galasaapi.NewGalasaSecret() secret.SetApiVersion(API_VERSION) @@ -30,10 +33,12 @@ func createMockGalasaSecretWithDescription(secretName string, description string secretMetadata.SetName(secretName) secretMetadata.SetEncoding(DUMMY_ENCODING) secretMetadata.SetType("UsernamePassword") + secretMetadata.SetLastUpdatedBy(DUMMY_USERNAME) + secretMetadata.SetLastUpdatedTime(time.Date(2024, 01, 01, 10, 0, 0, 0, time.UTC)) - if description != "" { - secretMetadata.SetDescription(description) - } + if description != "" { + secretMetadata.SetDescription(description) + } secretData := *galasaapi.NewGalasaSecretData() secretData.SetUsername(DUMMY_USERNAME) @@ -61,8 +66,8 @@ func TestSecretSummaryFormatterNoDataReturnsTotalCountAllZeros(t *testing.T) { func TestSecretSummaryFormatterSingleDataReturnsCorrectly(t *testing.T) { // Given... formatter := NewSecretSummaryFormatter() - description := "secret for system1" - secretName := "MYSECRET" + description := "secret for system1" + secretName := "MYSECRET" secret1 := createMockGalasaSecretWithDescription(secretName, description) secrets := []galasaapi.GalasaSecret{ secret1 } @@ -71,12 +76,12 @@ func TestSecretSummaryFormatterSingleDataReturnsCorrectly(t *testing.T) { // Then... assert.Nil(t, err) - expectedFormattedOutput := fmt.Sprintf( -`name type description -%s UsernamePassword %s + expectedFormattedOutput := +`name type last-updated(UTC) last-updated-by description +MYSECRET UsernamePassword 2024-01-01 10:00:00 dummy-username secret for system1 Total:1 -`, secretName, description) +` assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) } @@ -85,12 +90,12 @@ func TestSecretSummaryFormatterMultipleDataSeperatesWithNewLine(t *testing.T) { formatter := NewSecretSummaryFormatter() secrets := make([]galasaapi.GalasaSecret, 0) - secret1Name := "SECRET1" - secret1Description := "my first secret" - secret2Name := "SECRET2" - secret2Description := "my second secret" - secret3Name := "SECRET3" - secret3Description := "my third secret" + secret1Name := "SECRET1" + secret1Description := "my first secret" + secret2Name := "SECRET2" + secret2Description := "my second secret" + secret3Name := "SECRET3" + secret3Description := "my third secret" secret1 := createMockGalasaSecretWithDescription(secret1Name, secret1Description) secret2 := createMockGalasaSecretWithDescription(secret2Name, secret2Description) @@ -102,13 +107,13 @@ func TestSecretSummaryFormatterMultipleDataSeperatesWithNewLine(t *testing.T) { // Then... assert.Nil(t, err) - expectedFormattedOutput := fmt.Sprintf( -`name type description -%s UsernamePassword %s -%s UsernamePassword %s -%s UsernamePassword %s + expectedFormattedOutput := +`name type last-updated(UTC) last-updated-by description +SECRET1 UsernamePassword 2024-01-01 10:00:00 dummy-username my first secret +SECRET2 UsernamePassword 2024-01-01 10:00:00 dummy-username my second secret +SECRET3 UsernamePassword 2024-01-01 10:00:00 dummy-username my third secret Total:3 -`, secret1Name, secret1Description, secret2Name, secret2Description, secret3Name, secret3Description) +` assert.Equal(t, expectedFormattedOutput, actualFormattedOutput) } diff --git a/pkg/secretsformatter/yamlFormatter_test.go b/pkg/secretsformatter/yamlFormatter_test.go index 714e80a3..3f889e9f 100644 --- a/pkg/secretsformatter/yamlFormatter_test.go +++ b/pkg/secretsformatter/yamlFormatter_test.go @@ -18,15 +18,18 @@ func createMockGalasaSecret(secretName string) galasaapi.GalasaSecret { } func generateExpectedSecretYaml(secretName string) string { - return fmt.Sprintf(`apiVersion: %s + return fmt.Sprintf( +`apiVersion: %s kind: GalasaSecret metadata: name: %s + lastUpdatedTime: 2024-01-01T10:00:00Z + lastUpdatedBy: %s encoding: %s type: UsernamePassword data: username: %s - password: %s`, API_VERSION, secretName, DUMMY_ENCODING, DUMMY_USERNAME, DUMMY_PASSWORD) + password: %s`, API_VERSION, secretName, DUMMY_USERNAME, DUMMY_ENCODING, DUMMY_USERNAME, DUMMY_PASSWORD) } func TestSecretsYamlFormatterNoDataReturnsBlankString(t *testing.T) { diff --git a/pkg/utils/httpInteractionMock.go b/pkg/utils/httpInteractionMock.go index 6bb754fe..e28c746e 100644 --- a/pkg/utils/httpInteractionMock.go +++ b/pkg/utils/httpInteractionMock.go @@ -22,6 +22,8 @@ type HttpInteraction struct { // An override-able function to write a HTTP response for this interaction WriteHttpResponseFunc func(writer http.ResponseWriter, req *http.Request) + + ValidateRequestFunc func(t *testing.T, req *http.Request) } func NewHttpInteraction(expectedPath string, expectedHttpMethod string) HttpInteraction { @@ -35,6 +37,10 @@ func NewHttpInteraction(expectedPath string, expectedHttpMethod string) HttpInte writer.WriteHeader(http.StatusOK) } + httpInteraction.ValidateRequestFunc = func(t *testing.T, req *http.Request) { + // Do nothing... + } + return httpInteraction } @@ -42,6 +48,9 @@ func (interaction *HttpInteraction) ValidateRequest(t *testing.T, req *http.Requ assert.NotEmpty(t, req.Header.Get("ClientApiVersion")) assert.Equal(t, interaction.ExpectedHttpMethod, req.Method, "Actual HTTP request method did not match the expected method") assert.Equal(t, interaction.ExpectedPath, req.URL.Path, "Actual request path did not match the expected path") + + // Perform additional checks based on the possibly overridden function + interaction.ValidateRequestFunc(t, req) } //-----------------------------------------------------------------------------