diff --git a/pkg/app/ops/handler/handler.go b/pkg/app/ops/handler/handler.go index a4cc24b3d0..8a646cf037 100644 --- a/pkg/app/ops/handler/handler.go +++ b/pkg/app/ops/handler/handler.go @@ -35,16 +35,20 @@ import ( var templateFS embed.FS var ( - topPageTmpl = template.Must(template.ParseFS(templateFS, "templates/Top")) - listProjectsTmpl = template.Must(template.ParseFS(templateFS, "templates/ListProjects")) - applicationCountsTmpl = template.Must(template.ParseFS(templateFS, "templates/ApplicationCounts")) - addProjectTmpl = template.Must(template.ParseFS(templateFS, "templates/AddProject")) - addedProjectTmpl = template.Must(template.ParseFS(templateFS, "templates/AddedProject")) + topPageTmpl = template.Must(template.ParseFS(templateFS, "templates/Top")) + listProjectsTmpl = template.Must(template.ParseFS(templateFS, "templates/ListProjects")) + applicationCountsTmpl = template.Must(template.ParseFS(templateFS, "templates/ApplicationCounts")) + addProjectTmpl = template.Must(template.ParseFS(templateFS, "templates/AddProject")) + addedProjectTmpl = template.Must(template.ParseFS(templateFS, "templates/AddedProject")) + confirmPasswordResetTmpl = template.Must(template.ParseFS(templateFS, "templates/ConfirmPasswordReset")) + resetPasswordTmpl = template.Must(template.ParseFS(templateFS, "templates/ResetPassword")) ) type projectStore interface { Add(ctx context.Context, proj *model.Project) error List(ctx context.Context, opts datastore.ListOptions) ([]model.Project, error) + Get(ctx context.Context, id string) (*model.Project, error) + UpdateProjectStaticAdmin(ctx context.Context, id, username, password string) error } type Handler struct { @@ -72,6 +76,7 @@ func NewHandler(port int, ps projectStore, sharedSSOConfigs []config.SharedSSOCo mux.HandleFunc("/", h.handleTop) mux.HandleFunc("/projects", h.handleListProjects) mux.HandleFunc("/projects/add", h.handleAddProject) + mux.HandleFunc("/projects/reset-password", h.handleResetPassword) return h } @@ -152,6 +157,106 @@ func (h *Handler) handleListProjects(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) getProjectByIDOrReturnError(id string, w http.ResponseWriter) *model.Project { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + project, err := h.projectStore.Get(ctx, id) + if err != nil { + h.logger.Error("failed to retrieve existing project", + zap.String("id", id), + zap.Error(err), + ) + http.Error(w, fmt.Sprintf("Unable to retrieve existing project (%v)", err), http.StatusInternalServerError) + return nil + } + return project +} + +func (h *Handler) confirmPasswordReset(w http.ResponseWriter, r *http.Request, optionalErrorMessage string) { + id := html.EscapeString(r.URL.Query().Get("ID")) + if id == "" { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + project := h.getProjectByIDOrReturnError(id, w) + if project == nil { + return + } + data := map[string]string{ + "ID": id, + "Description": project.Desc, + "StaticAdminUsername": project.GetStaticAdminUsername(), + "CreatedAt": time.Unix(project.CreatedAt, 0).String(), + "ErrorMessage": optionalErrorMessage, + } + if err := confirmPasswordResetTmpl.Execute(w, data); err != nil { + h.logger.Error("failed to render ConfirmResetPassword page template", zap.Error(err)) + } + +} + +func (h *Handler) handleResetPassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Method == http.MethodGet { + h.confirmPasswordReset(w, r, "") + return + } + id := html.EscapeString(r.FormValue("ID")) + if id == "" { + h.confirmPasswordReset(w, r, "Missing ID Parameter") + return + } + confirmationID := html.EscapeString(r.FormValue("confirmationID")) + if confirmationID == "" { + h.confirmPasswordReset(w, r, "Missing confirmation ID") + return + } + + if id != confirmationID { + h.confirmPasswordReset(w, r, "Confirmation ID doesn't match") + return + } + + // get the existing project model + project := h.getProjectByIDOrReturnError(id, w) + if project == nil { + return + } + + // get the username and account for NULLs or blank strings (the model supports both) + var username = project.GetStaticAdminUsername() + if username == "" { + username = model.GenerateRandomString(10) + } + + // generate a new password + password := model.GenerateRandomString(30) + + // update the details + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := h.projectStore.UpdateProjectStaticAdmin(ctx, id, username, password); err != nil { + h.logger.Error("failed to update static admin", + zap.String("id", id), + zap.Error(err), + ) + http.Error(w, fmt.Sprintf("Unable to reset the password for project (%v)", err), http.StatusInternalServerError) + return + } + + data := map[string]string{ + "ID": project.Id, + "StaticAdminUsername": username, + "StaticAdminPassword": password, + } + if err := resetPasswordTmpl.Execute(w, data); err != nil { + h.logger.Error("failed to render ResetPassword page template", zap.Error(err)) + } +} + func (h *Handler) handleAddProject(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "not found", http.StatusNotFound) @@ -232,13 +337,3 @@ func (h *Handler) handleAddProject(w http.ResponseWriter, r *http.Request) { h.logger.Error("failed to render AddedProject page template", zap.Error(err)) } } - -func groupApplicationCounts(counts []model.InsightApplicationCount) (total int, groups map[string]int) { - groups = make(map[string]int) - for _, c := range counts { - total += int(c.Count) - kind := c.Labels[model.InsightApplicationCountLabelKey_KIND.String()] - groups[kind] += int(c.Count) - } - return -} diff --git a/pkg/app/ops/handler/handler_mock.go b/pkg/app/ops/handler/handler_mock.go new file mode 100644 index 0000000000..292d7b3d6a --- /dev/null +++ b/pkg/app/ops/handler/handler_mock.go @@ -0,0 +1,95 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: pkg/app/ops/handler/handler.go + +// Package handler is a generated GoMock package. +package handler + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + datastore "github.com/pipe-cd/pipecd/pkg/datastore" + model "github.com/pipe-cd/pipecd/pkg/model" +) + +// MockprojectStore is a mock of projectStore interface. +type MockprojectStore struct { + ctrl *gomock.Controller + recorder *MockprojectStoreMockRecorder +} + +// MockprojectStoreMockRecorder is the mock recorder for MockprojectStore. +type MockprojectStoreMockRecorder struct { + mock *MockprojectStore +} + +// NewMockprojectStore creates a new mock instance. +func NewMockprojectStore(ctrl *gomock.Controller) *MockprojectStore { + mock := &MockprojectStore{ctrl: ctrl} + mock.recorder = &MockprojectStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockprojectStore) EXPECT() *MockprojectStoreMockRecorder { + return m.recorder +} + +// Add mocks base method. +func (m *MockprojectStore) Add(ctx context.Context, proj *model.Project) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", ctx, proj) + ret0, _ := ret[0].(error) + return ret0 +} + +// Add indicates an expected call of Add. +func (mr *MockprojectStoreMockRecorder) Add(ctx, proj interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockprojectStore)(nil).Add), ctx, proj) +} + +// Get mocks base method. +func (m *MockprojectStore) Get(ctx context.Context, id string) (*model.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, id) + ret0, _ := ret[0].(*model.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockprojectStoreMockRecorder) Get(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockprojectStore)(nil).Get), ctx, id) +} + +// List mocks base method. +func (m *MockprojectStore) List(ctx context.Context, opts datastore.ListOptions) ([]model.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, opts) + ret0, _ := ret[0].([]model.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockprojectStoreMockRecorder) List(ctx, opts interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockprojectStore)(nil).List), ctx, opts) +} + +// UpdateProjectStaticAdmin mocks base method. +func (m *MockprojectStore) UpdateProjectStaticAdmin(ctx context.Context, id, username, password string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateProjectStaticAdmin", ctx, id, username, password) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateProjectStaticAdmin indicates an expected call of UpdateProjectStaticAdmin. +func (mr *MockprojectStoreMockRecorder) UpdateProjectStaticAdmin(ctx, id, username, password interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProjectStaticAdmin", reflect.TypeOf((*MockprojectStore)(nil).UpdateProjectStaticAdmin), ctx, id, username, password) +} \ No newline at end of file diff --git a/pkg/app/ops/handler/handler_test.go b/pkg/app/ops/handler/handler_test.go index 06eacc1459..5e357d2f45 100644 --- a/pkg/app/ops/handler/handler_test.go +++ b/pkg/app/ops/handler/handler_test.go @@ -16,78 +16,214 @@ package handler import ( "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "github.com/pipe-cd/pipecd/pkg/config" "github.com/pipe-cd/pipecd/pkg/model" ) -func TestMakeApplicationCounts(t *testing.T) { +func createMockHandler(ctrl *gomock.Controller) (*MockprojectStore, *Handler) { + m := NewMockprojectStore(ctrl) + logger, _ := zap.NewProduction() + + h := NewHandler( + 10101, + m, + []config.SharedSSOConfig{}, + 0, + logger, + ) + + return m, h +} + +func setupMockHandler(ctrl *gomock.Controller, errToReturn error) (*Handler, string, *model.Project) { + m, h := createMockHandler(ctrl) + + // fake details + id := "test_id" + project := &model.Project{ + Id: id, + } + + if errToReturn == nil { + // mock the call to the project store + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(project, nil) + } else { + // mock the call to the project store + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(nil, errToReturn) + } + + return h, id, project +} + +func TestGetProjectByIDOrReturnError(t *testing.T) { + // Create a new controller + ctrl := gomock.NewController(t) + + h, id, project := setupMockHandler(ctrl, nil) + w := httptest.NewRecorder() + + actualProject := h.getProjectByIDOrReturnError(id, w) + + assert.Equal(t, project, actualProject) +} + +func TestGetProjectByIDOrReturnErrorError(t *testing.T) { + // Create a new controller + ctrl := gomock.NewController(t) + + h, id, _ := setupMockHandler(ctrl, errors.New("example error")) + w := httptest.NewRecorder() + + actualProject := h.getProjectByIDOrReturnError(id, w) + + assert.Nil(t, actualProject) + + res := w.Result() + assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + + defer res.Body.Close() + data, _ := io.ReadAll(res.Body) + + assert.Equal(t, "Unable to retrieve existing project (example error)\n", string(data)) +} + +func TestRun(t *testing.T) { + // Create a new controller + ctrl := gomock.NewController(t) + _, h := createMockHandler(ctrl) + + go func() { + h.Run(context.TODO()) + }() + err := h.stop() + + assert.Nil(t, err) +} + +func buildHandleResetPasswordRequest(method string, id string, confirmationID string) *http.Request { + req, _ := http.NewRequest(method, "/projects/reset-password", nil) + + q := req.URL.Query() + if id != "" { + q.Add("ID", id) + } + if confirmationID != "" { + q.Add("confirmationID", confirmationID) + } + req.URL.RawQuery = q.Encode() + + return req +} + +func TestHandleResetPassword(t *testing.T) { + // Create a new controller + ctrl := gomock.NewController(t) + m, h := createMockHandler(ctrl) + + // fake details + id := "test_id" + project := &model.Project{ + Id: id, + } + testcases := []struct { name string - counts []model.InsightApplicationCount - expectedTotal int - expectedGroups map[string]int + req *http.Request + expectedStatus int + expectedBody string + extraMocks func() }{ { - name: "empty", - expectedGroups: map[string]int{}, + name: "wrong method", + req: buildHandleResetPasswordRequest("PUT", "", ""), + expectedStatus: http.StatusNotFound, + expectedBody: "not found", }, { - name: "one count", - counts: []model.InsightApplicationCount{ - { - Labels: map[string]string{ - "KIND": "KUBERNETES", - "ACTIVITY_STATUS": "ENABLED", - }, - Count: 5, - }, + name: "get returns confirmation page", + req: buildHandleResetPasswordRequest("GET", id, ""), + expectedStatus: http.StatusOK, + expectedBody: fmt.Sprintf("Confirm you want to reset the static admin password for %s", id), + extraMocks: func() { + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(project, nil) }, - expectedTotal: 5, - expectedGroups: map[string]int{ - "KUBERNETES": 5, + }, + { + name: "missing-id-from-query-and-post", + req: buildHandleResetPasswordRequest("POST", "", ""), + expectedStatus: http.StatusBadRequest, + expectedBody: "invalid id", + }, + { + name: "missing-confirmation-id", + req: buildHandleResetPasswordRequest("POST", id, ""), + expectedStatus: http.StatusOK, + expectedBody: "Missing confirmation ID", + extraMocks: func() { + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(project, nil) }, }, { - name: "multiple counts", - counts: []model.InsightApplicationCount{ - { - Labels: map[string]string{ - "KIND": "KUBERNETES", - "ACTIVITY_STATUS": "ENABLED", - }, - Count: 5, - }, - { - Labels: map[string]string{ - "KIND": "KUBERNETES", - "ACTIVITY_STATUS": "DISABLED", - }, - Count: 3, - }, - { - Labels: map[string]string{ - "KIND": "LAMBDA", - "ACTIVITY_STATUS": "ENABLED", - }, - Count: 2, - }, + name: "wrong-confirmation-id", + req: buildHandleResetPasswordRequest("POST", id, fmt.Sprintf("%s mis match", id)), + expectedStatus: http.StatusOK, + expectedBody: "Confirmation ID doesn't match", + extraMocks: func() { + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(project, nil) }, - expectedTotal: 10, - expectedGroups: map[string]int{ - "KUBERNETES": 8, - "LAMBDA": 2, + }, + { + name: "valid-reset-post", + req: buildHandleResetPasswordRequest("POST", id, id), + expectedStatus: http.StatusOK, + expectedBody: "Successfully reset password for project", + extraMocks: func() { + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(project, nil) + m.EXPECT().UpdateProjectStaticAdmin(gomock.Any(), id, gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "unable to update project static admin", + req: buildHandleResetPasswordRequest("POST", id, id), + expectedStatus: http.StatusInternalServerError, + expectedBody: "Unable to reset the password for project", + extraMocks: func() { + m.EXPECT().Get(gomock.Any(), gomock.Eq(id)).Return(project, nil) + m.EXPECT().UpdateProjectStaticAdmin(gomock.Any(), id, gomock.Any(), gomock.Any()).Return(errors.New("error updating admin")) }, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - total, groups := groupApplicationCounts(tc.counts) - assert.Equal(t, tc.expectedTotal, total) - assert.Equal(t, tc.expectedGroups, groups) + w := httptest.NewRecorder() + + if tc.extraMocks != nil { + tc.extraMocks() + } + + h.handleResetPassword(w, tc.req) + + res := w.Result() + assert.Equal(t, tc.expectedStatus, res.StatusCode) + + defer res.Body.Close() + data, _ := io.ReadAll(res.Body) + + assert.True(t, strings.Contains(string(data), tc.expectedBody)) }) } } diff --git a/pkg/app/ops/handler/templates/ConfirmPasswordReset b/pkg/app/ops/handler/templates/ConfirmPasswordReset new file mode 100644 index 0000000000..b4b250ce7e --- /dev/null +++ b/pkg/app/ops/handler/templates/ConfirmPasswordReset @@ -0,0 +1,73 @@ + + + + + + + + + +

Welcome to PipeCD Owner Page!

+ + {{ if .ErrorMessage }} +

+ {{ .ErrorMessage }} +

+ {{ end }} + +

Confirm you want to reset the static admin password for {{ .ID }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
ID{{ .ID }}
Description{{ .Description }}
Static Admin Username{{ .StaticAdminUsername }}
Created At{{ .CreatedAt }}
Enter ID to confirm +
+ +

+ +
+
+ + + + + \ No newline at end of file diff --git a/pkg/app/ops/handler/templates/ListProjects b/pkg/app/ops/handler/templates/ListProjects index d63ef8b5c5..c20eebf807 100644 --- a/pkg/app/ops/handler/templates/ListProjects +++ b/pkg/app/ops/handler/templates/ListProjects @@ -1,51 +1,57 @@ + - + + -

Welcome to PipeCD Owner Page!

- -

There are {{ len . }} registered projects

- - - - - - - - - - -{{ range $index, $project := . }} - - - - - - - - -{{ end }} - -
IndexIDDescriptionStatic Admin DisabledShared SSO NameCreated At
{{ $index }}{{ $project.ID }}{{ $project.Description }}{{ $project.StaticAdminDisabled }}{{ $project.SharedSSOName }}{{ $project.CreatedAt }}
+

Welcome to PipeCD Owner Page!

+ +

There are {{ len . }} registered projects

+ + + + + + + + + + + + {{ range $index, $project := . }} + + + + + + + + + + {{ end }} + +
IndexIDDescriptionStatic Admin DisabledShared SSO NameCreated AtReset Static Admin Password
{{ $index }}{{ $project.ID }}{{ $project.Description }}{{ $project.StaticAdminDisabled }}{{ $project.SharedSSOName }}{{ $project.CreatedAt }}Reset Password
- + + \ No newline at end of file diff --git a/pkg/app/ops/handler/templates/ResetPassword b/pkg/app/ops/handler/templates/ResetPassword new file mode 100644 index 0000000000..1db1e7714d --- /dev/null +++ b/pkg/app/ops/handler/templates/ResetPassword @@ -0,0 +1,48 @@ + + + + + + + +

Welcome to PipeCD Owner Page!

+ +

Successfully reset password for project {{ .ID }}

+ + + + + + + + + + + + + + + + + + +
FieldDescription
ID{{ .ID }}
Current Static Admin Username{{ .StaticAdminUsername }}
New Static Admin Password{{ .StaticAdminPassword }}
+ + + \ No newline at end of file diff --git a/pkg/model/project.pb.go b/pkg/model/project.pb.go index ee386699c7..b701b02a1d 100644 --- a/pkg/model/project.pb.go +++ b/pkg/model/project.pb.go @@ -305,6 +305,15 @@ func (x *Project) GetStaticAdminDisabled() bool { return false } +func (x *Project) GetStaticAdminUsername() string { + var username = "" + staticAdmin := x.GetStaticAdmin() + if staticAdmin != nil { + username = staticAdmin.GetUsername() + } + return username +} + func (x *Project) GetSso() *ProjectSSOConfig { if x != nil { return x.Sso @@ -1282,4 +1291,4 @@ func file_pkg_model_project_proto_init() { file_pkg_model_project_proto_rawDesc = nil file_pkg_model_project_proto_goTypes = nil file_pkg_model_project_proto_depIdxs = nil -} +} \ No newline at end of file