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