diff --git a/internal/config/config.go b/internal/config/config.go index 48f4090e3..ca3733c44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,3 +106,24 @@ func (cfg *Config) WriteFile(path util.AbsolutePath) error { defer f.Close() return cfg.Write(f) } + +func (cfg *Config) AddSecret(secret string) error { + // Check if the secret already exists before adding + for _, s := range cfg.Secrets { + if s == secret { + return nil // Secret already exists, no need to add + } + } + cfg.Secrets = append(cfg.Secrets, secret) + return nil +} + +func (cfg *Config) RemoveSecret(secret string) error { + for i, s := range cfg.Secrets { + if s == secret { + cfg.Secrets = append(cfg.Secrets[:i], cfg.Secrets[i+1:]...) + break + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0632db25f..0ce88dffa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -156,3 +156,44 @@ func (s *ConfigSuite) TestReadComments() { s.Equal([]string{" These are comments.", " They will be preserved."}, cfg.Comments) } + +func (s *ConfigSuite) TestApplySecretActionAdd() { + cfg := New() + cfg.Secrets = []string{} + err := cfg.AddSecret("secret1") + s.NoError(err) + s.Equal([]string{"secret1"}, cfg.Secrets) +} + +func (s *ConfigSuite) TestApplySecretActionAddWithExistingSecrets() { + cfg := New() + cfg.Secrets = []string{"existingSecret1", "existingSecret2"} + err := cfg.AddSecret("newSecret") + s.NoError(err) + s.Equal([]string{"existingSecret1", "existingSecret2", "newSecret"}, cfg.Secrets) +} + +func (s *ConfigSuite) TestApplySecretActionAddNoDuplicates() { + cfg := New() + cfg.Secrets = []string{"existingSecret1", "existingSecret2"} + + err := cfg.AddSecret("existingSecret1") + s.NoError(err) + s.Equal([]string{"existingSecret1", "existingSecret2"}, cfg.Secrets) +} + +func (s *ConfigSuite) TestApplySecretActionRemove() { + cfg := New() + cfg.Secrets = []string{"secret1", "secret2"} + err := cfg.RemoveSecret("secret1") + s.NoError(err) + s.Equal([]string{"secret2"}, cfg.Secrets) +} + +func (s *ConfigSuite) TestApplySecretActionRemoveFromEmptySecrets() { + cfg := New() + cfg.Secrets = []string{} + err := cfg.RemoveSecret("nonexistentSecret") + s.NoError(err) + s.Equal([]string{}, cfg.Secrets) +} diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 280c30285..d9b5e1ad0 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -130,6 +130,10 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("configurations", "{name}", "secrets"), GetConfigSecretsHandlerFunc(base, log)). Methods(http.MethodGet) + // POST /api/configurations/$NAME/secrets + r.Handle(ToPath("configurations", "{name}", "secrets"), PostConfigSecretsHandlerFunc(base, log)). + Methods(http.MethodPost) + // GET /api/configurations/$NAME/packages/python r.Handle(ToPath("configurations", "{name}", "packages", "python"), NewGetConfigPythonPackagesHandler(base, log)). Methods(http.MethodGet) diff --git a/internal/services/api/post_config_secrets.go b/internal/services/api/post_config_secrets.go new file mode 100644 index 000000000..e9f12055e --- /dev/null +++ b/internal/services/api/post_config_secrets.go @@ -0,0 +1,113 @@ +package api + +// Copyright (C) 2024 by Posit Software, PBC. + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + + "github.com/gorilla/mux" + "github.com/posit-dev/publisher/internal/config" + "github.com/posit-dev/publisher/internal/logging" + "github.com/posit-dev/publisher/internal/types" + "github.com/posit-dev/publisher/internal/util" +) + +const ( + secretActionAdd = "add" + secretActionRemove = "remove" +) + +type postConfigSecretsRequest struct { + Action string `json:"action"` + Secret string `json:"secret"` +} + +func applySecretAction(cfg *config.Config, action string, secret string) error { + switch action { + case secretActionAdd: + return cfg.AddSecret(secret) + case secretActionRemove: + return cfg.RemoveSecret(secret) + default: + return fmt.Errorf("unknown action: %s", action) + } +} + +func PostConfigSecretsHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + name := mux.Vars(req)["name"] + + projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) + if err != nil { + // Response already returned by ProjectDirFromRequest + return + } + + configPath := config.GetConfigPath(projectDir, name) + cfg, err := configFromFile(configPath) + if err != nil { + if aerr, ok := err.(*types.AgentError); ok { + if aerr.Code == types.ErrorUnknownTOMLKey { + apiErr := APIErrorUnknownTOMLKeyFromAgentError(*aerr) + apiErr.JSONResponse(w) + return + } + + if aerr.Code == types.ErrorInvalidTOML { + apiErr := APIErrorInvalidTOMLFileFromAgentError(*aerr) + apiErr.JSONResponse(w) + return + } + } + + if errors.Is(err, fs.ErrNotExist) { + http.NotFound(w, req) + } else { + InternalError(w, req, log, err) + } + return + } + + dec := json.NewDecoder(req.Body) + dec.DisallowUnknownFields() + var b postConfigSecretsRequest + err = dec.Decode(&b) + if err != nil { + BadRequest(w, req, log, err) + return + } + + err = applySecretAction(cfg, b.Action, b.Secret) + if err != nil { + BadRequest(w, req, log, err) + return + } + + err = cfg.WriteFile(configPath) + if err != nil { + InternalError(w, req, log, err) + return + } + + relPath, err := configPath.Rel(base) + if err != nil { + InternalError(w, req, log, err) + return + } + + response := &configDTO{ + configLocation: configLocation{ + Name: name, + Path: configPath.String(), + RelPath: relPath.String(), + }, + ProjectDir: relProjectDir.String(), + Configuration: cfg, + } + JsonResult(w, http.StatusOK, response) + } +} diff --git a/internal/services/api/post_config_secrets_test.go b/internal/services/api/post_config_secrets_test.go new file mode 100644 index 000000000..95cdec99a --- /dev/null +++ b/internal/services/api/post_config_secrets_test.go @@ -0,0 +1,155 @@ +package api + +// Copyright (C) 2024 by Posit Software, PBC. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" + "github.com/posit-dev/publisher/internal/config" + "github.com/posit-dev/publisher/internal/logging" + "github.com/posit-dev/publisher/internal/util" + "github.com/posit-dev/publisher/internal/util/utiltest" + "github.com/spf13/afero" + "github.com/stretchr/testify/suite" +) + +type ApplySecretActionSuite struct { + utiltest.Suite +} + +func TestApplySecretActionSuite(t *testing.T) { + suite.Run(t, new(ApplySecretActionSuite)) +} + +func (s *ApplySecretActionSuite) TestApplySecretActionAdd() { + cfg := config.New() + cfg.Secrets = []string{} + err := applySecretAction(cfg, secretActionAdd, "secret1") + s.NoError(err) + s.Equal([]string{"secret1"}, cfg.Secrets) +} + +func (s *ApplySecretActionSuite) TestApplySecretActionRemove() { + cfg := config.New() + cfg.Secrets = []string{"secret1", "secret2"} + err := applySecretAction(cfg, secretActionRemove, "secret1") + s.NoError(err) + s.Equal([]string{"secret2"}, cfg.Secrets) +} + +func (s *ApplySecretActionSuite) TestApplySecretActionUnknownAction() { + cfg := config.New() + err := applySecretAction(cfg, "invalidAction", "someSecret") + s.Error(err) + s.Equal("unknown action: invalidAction", err.Error()) +} + +type PostConfigSecretsSuite struct { + utiltest.Suite + cwd util.AbsolutePath + log logging.Logger + h http.HandlerFunc +} + +func TestPostConfigSecretsSuite(t *testing.T) { + suite.Run(t, new(PostConfigSecretsSuite)) +} + +func (s *PostConfigSecretsSuite) SetupSuite() { + s.log = logging.New() +} + +func (s *PostConfigSecretsSuite) SetupTest() { + fs := afero.NewMemMapFs() + cwd, err := util.Getwd(fs) + s.Nil(err) + s.cwd = cwd + s.h = PostConfigSecretsHandlerFunc(s.cwd, s.log) +} + +func (s *PostConfigSecretsSuite) TestPostConfigSecretsAdd() { + cfg := config.New() + cfg.Type = config.ContentTypeHTML + err := cfg.WriteFile(config.GetConfigPath(s.cwd, "myConfig")) + s.NoError(err) + + body := strings.NewReader(`{"action": "add", "secret": "test_secret"}`) + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/configurations/myConfig/secrets", body) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) + + s.h(rec, req) + + s.Equal(http.StatusOK, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + var res configDTO + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(&res)) + s.NotNil(res) + s.Equal([]string{"test_secret"}, res.Configuration.Secrets) +} + +func (s *PostConfigSecretsSuite) TestPostConfigSecretsRemove() { + cfg := config.New() + cfg.Type = config.ContentTypeHTML + cfg.Secrets = []string{"existing_secret", "test_secret"} + err := cfg.WriteFile(config.GetConfigPath(s.cwd, "myConfig")) + s.NoError(err) + + body := strings.NewReader(`{"action": "remove", "secret": "test_secret"}`) + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/configurations/myConfig/secrets", body) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) + + s.h(rec, req) + + s.Equal(http.StatusOK, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + var res configDTO + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(&res)) + s.NotNil(res) + s.Equal([]string{"existing_secret"}, res.Configuration.Secrets) +} + +func (s *PostConfigSecretsSuite) TestPostConfigSecretsNotFound() { + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/configurations/myConfig/secrets", nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) + + s.h(rec, req) + + s.Equal(http.StatusNotFound, rec.Result().StatusCode) +} + +func (s *PostConfigSecretsSuite) TestPostConfigSecretsInvalidAction() { + cfg := config.New() + cfg.Type = config.ContentTypeHTML + err := cfg.WriteFile(config.GetConfigPath(s.cwd, "myConfig")) + s.NoError(err) + + body := strings.NewReader(`{"action": "invalid", "secret": "test_secret"}`) + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/configurations/myConfig/secrets", body) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) + + s.h(rec, req) + + s.Equal(http.StatusBadRequest, rec.Result().StatusCode) + + bodyRes := rec.Body.String() + s.Contains(bodyRes, "Bad Request: unknown action: invalid") +}