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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ if .ErrorMessage }}
+
+ {{ .ErrorMessage }}
+
+ {{ end }}
+
+ Confirm you want to reset the static admin password for {{ .ID }}
+
+
+
+ Field |
+ Description |
+
+
+ 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 @@
+
-
+
+
-
-
-There are {{ len . }} registered projects
-
-
-
- Index |
- ID |
- Description |
- Static Admin Disabled |
- Shared SSO Name |
- Created At |
-
-{{ range $index, $project := . }}
-
- {{ $index }} |
- {{ $project.ID }} |
- {{ $project.Description }} |
- {{ $project.StaticAdminDisabled }} |
- {{ $project.SharedSSOName }} |
- {{ $project.CreatedAt }} |
-
-{{ end }}
-
-
+
+
+ There are {{ len . }} registered projects
+
+
+
+ Index |
+ ID |
+ Description |
+ Static Admin Disabled |
+ Shared SSO Name |
+ Created At |
+ Reset Static Admin Password |
+
+ {{ range $index, $project := . }}
+
+ {{ $index }} |
+ {{ $project.ID }} |
+ {{ $project.Description }} |
+ {{ $project.StaticAdminDisabled }} |
+ {{ $project.SharedSSOName }} |
+ {{ $project.CreatedAt }} |
+ Reset Password |
+
+ {{ end }}
+
+
-
+
+