From 66b3088e54c7f3d134d43696d488532920e2ee54 Mon Sep 17 00:00:00 2001 From: Easton Crupper <65553218+ecrupper@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:55:05 -0400 Subject: [PATCH] feat(dashboards)!: server-side implementation of dashboards (#1028) * init commit * split up org and name on GET return * add sender * admin validation abstraction * add build link * feat: adding new user changes to server (#1021) Co-authored-by: Claire.Nicholas * some edits and list user + uuid usage * yep * sooooo many tests to add * add list build dashboard test and remove test json file * imports order and fix integration test * update swagger * make clean * fix spec * address feedback and fix more comments * address feedback and change admin set to use names * convert admin set to nested sanitized user list * fix a comment --------- Co-authored-by: claire1618 <55173466+claire1618@users.noreply.github.com> Co-authored-by: Claire.Nicholas --- api/dashboard/create.go | 190 ++++++++++++ api/dashboard/delete.go | 93 ++++++ api/dashboard/get.go | 132 ++++++++ api/dashboard/list_user.go | 102 +++++++ api/dashboard/update.go | 140 +++++++++ api/types/dashboard.go | 281 ++++++++++++++++++ api/types/dashboard_repo.go | 136 +++++++++ api/types/dashboard_repo_test.go | 126 ++++++++ api/types/dashboard_test.go | 174 +++++++++++ api/types/events.go | 2 +- api/types/user.go | 29 ++ api/types/user_test.go | 12 + api/user/update.go | 5 + api/user/update_current.go | 6 + constants/limit.go | 8 + constants/table.go | 8 + database/build/count_org_test.go | 3 +- database/build/interface.go | 2 + database/build/list_dashboard.go | 58 ++++ database/build/list_dashboard_test.go | 111 +++++++ database/build/list_org_test.go | 3 +- .../build/list_pending_running_repo_test.go | 3 +- database/build/list_pending_running_test.go | 3 +- database/dashboard/create.go | 31 ++ database/dashboard/create_test.go | 95 ++++++ database/dashboard/dashboard.go | 248 ++++++++++++++++ database/dashboard/dashboard_test.go | 219 ++++++++++++++ database/dashboard/delete.go | 27 ++ database/dashboard/delete_test.go | 77 +++++ database/dashboard/get.go | 30 ++ database/dashboard/get_test.go | 103 +++++++ database/dashboard/interface.go | 35 +++ database/dashboard/opts.go | 62 ++++ database/dashboard/opts_test.go | 208 +++++++++++++ database/dashboard/table.go | 60 ++++ database/dashboard/table_test.go | 58 ++++ database/dashboard/update.go | 34 +++ database/dashboard/update_test.go | 97 ++++++ database/database.go | 2 + database/integration_test.go | 167 +++++++++-- database/interface.go | 4 + database/repo/get_org_test.go | 3 +- database/repo/get_test.go | 3 +- database/repo/list_org_test.go | 2 +- database/repo/list_test.go | 3 +- database/repo/list_user_test.go | 2 +- database/resource.go | 11 + database/resource_test.go | 3 + database/user/create_test.go | 6 +- database/user/get_name_test.go | 5 +- database/user/get_test.go | 5 +- database/user/list_lite_test.go | 4 + database/user/list_test.go | 8 +- database/user/table.go | 2 + database/user/update_test.go | 6 +- database/user/user.go | 28 +- database/user/user_test.go | 31 +- mock/server/user.go | 9 +- router/dashboard.go | 29 ++ router/middleware/dashboard/context.go | 37 +++ router/middleware/dashboard/context_test.go | 88 ++++++ router/middleware/dashboard/dashboard.go | 61 ++++ router/middleware/dashboard/dashboard_test.go | 162 ++++++++++ router/middleware/user/user_test.go | 1 + router/router.go | 2 +- router/user.go | 5 +- 66 files changed, 3634 insertions(+), 66 deletions(-) create mode 100644 api/dashboard/create.go create mode 100644 api/dashboard/delete.go create mode 100644 api/dashboard/get.go create mode 100644 api/dashboard/list_user.go create mode 100644 api/dashboard/update.go create mode 100644 api/types/dashboard.go create mode 100644 api/types/dashboard_repo.go create mode 100644 api/types/dashboard_repo_test.go create mode 100644 api/types/dashboard_test.go create mode 100644 constants/limit.go create mode 100644 constants/table.go create mode 100644 database/build/list_dashboard.go create mode 100644 database/build/list_dashboard_test.go create mode 100644 database/dashboard/create.go create mode 100644 database/dashboard/create_test.go create mode 100644 database/dashboard/dashboard.go create mode 100644 database/dashboard/dashboard_test.go create mode 100644 database/dashboard/delete.go create mode 100644 database/dashboard/delete_test.go create mode 100644 database/dashboard/get.go create mode 100644 database/dashboard/get_test.go create mode 100644 database/dashboard/interface.go create mode 100644 database/dashboard/opts.go create mode 100644 database/dashboard/opts_test.go create mode 100644 database/dashboard/table.go create mode 100644 database/dashboard/table_test.go create mode 100644 database/dashboard/update.go create mode 100644 database/dashboard/update_test.go create mode 100644 router/dashboard.go create mode 100644 router/middleware/dashboard/context.go create mode 100644 router/middleware/dashboard/context_test.go create mode 100644 router/middleware/dashboard/dashboard.go create mode 100644 router/middleware/dashboard/dashboard_test.go diff --git a/api/dashboard/create.go b/api/dashboard/create.go new file mode 100644 index 000000000..c0e8f0029 --- /dev/null +++ b/api/dashboard/create.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation POST /api/v1/dashboards dashboards CreateDashboard +// +// Create a dashboard in the configured backend +// +// --- +// produces: +// - application/json +// parameters: +// - in: body +// name: body +// description: Payload containing the dashboard to create +// required: true +// schema: +// "$ref": "#/definitions/Dashboard" +// security: +// - ApiKeyAuth: [] +// responses: +// '201': +// description: Successfully created dashboard +// schema: +// "$ref": "#/definitions/Dashboard" +// '400': +// description: Bad request when creating dashboard +// schema: +// "$ref": "#/definitions/Error" +// '401': +// description: Unauthorized to create dashboard +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Server error when creating dashboard +// schema: +// "$ref": "#/definitions/Error" + +// CreateDashboard represents the API handler to +// create a dashboard in the configured backend. +func CreateDashboard(c *gin.Context) { + // capture middleware values + u := user.Retrieve(c) + + // capture body from API request + input := new(types.Dashboard) + + err := c.Bind(input) + if err != nil { + retErr := fmt.Errorf("unable to decode JSON for new dashboard: %w", err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // ensure dashboard name is defined + if input.GetName() == "" { + util.HandleError(c, http.StatusBadRequest, fmt.Errorf("dashboard name must be set")) + + return + } + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "user": u.GetName(), + }).Infof("creating new dashboard %s", input.GetName()) + + d := new(types.Dashboard) + + // update fields in dashboard object + d.SetCreatedBy(u.GetName()) + d.SetName(input.GetName()) + d.SetCreatedAt(time.Now().UTC().Unix()) + d.SetUpdatedAt(time.Now().UTC().Unix()) + d.SetUpdatedBy(u.GetName()) + + // validate admins to ensure they are all active users + admins, err := createAdminSet(c, u, input.GetAdmins()) + if err != nil { + util.HandleError(c, http.StatusBadRequest, err) + + return + } + + d.SetAdmins(admins) + + // validate repos to ensure they are all enabled + err = validateRepoSet(c, input.GetRepos()) + if err != nil { + util.HandleError(c, http.StatusBadRequest, err) + + return + } + + d.SetRepos(input.GetRepos()) + + // create dashboard in database + d, err = database.FromContext(c).CreateDashboard(c, d) + if err != nil { + retErr := fmt.Errorf("unable to create new dashboard %s: %w", d.GetName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + // add dashboard to claims' user's dashboard set + u.SetDashboards(append(u.GetDashboards(), d.GetID())) + + // update user in database + _, err = database.FromContext(c).UpdateUser(c, u) + if err != nil { + retErr := fmt.Errorf("unable to update user %s: %w", u.GetName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + c.JSON(http.StatusCreated, d) +} + +// createAdminSet takes a slice of users, cleanses it of duplicates and throws an error +// when a user is inactive or not found in the database. It returns a sanitized slice of admins. +func createAdminSet(c context.Context, caller *types.User, users []*types.User) ([]*types.User, error) { + // add user creating the dashboard to admin list + admins := []*types.User{caller.Sanitize()} + + dupMap := make(map[string]bool) + + // validate supplied admins are actual users + for _, u := range users { + if u.GetName() == caller.GetName() || dupMap[u.GetName()] { + continue + } + + dbUser, err := database.FromContext(c).GetUserForName(c, u.GetName()) + if err != nil || !dbUser.GetActive() { + return nil, fmt.Errorf("unable to create dashboard: %s is not an active user", u.GetName()) + } + + admins = append(admins, dbUser.Sanitize()) + + dupMap[dbUser.GetName()] = true + } + + return admins, nil +} + +// validateRepoSet is a helper function that confirms all dashboard repos exist and are enabled +// in the database while also confirming the IDs match when saving. +func validateRepoSet(c context.Context, repos []*types.DashboardRepo) error { + for _, repo := range repos { + // verify format (org/repo) + parts := strings.Split(repo.GetName(), "/") + if len(parts) != 2 { + return fmt.Errorf("unable to create dashboard: %s is not a valid repo", repo.GetName()) + } + + // fetch repo from database + dbRepo, err := database.FromContext(c).GetRepoForOrg(c, parts[0], parts[1]) + if err != nil || !dbRepo.GetActive() { + return fmt.Errorf("unable to create dashboard: could not get repo %s: %w", repo.GetName(), err) + } + + // override ID field if provided to match the database ID + repo.SetID(dbRepo.GetID()) + } + + return nil +} diff --git a/api/dashboard/delete.go b/api/dashboard/delete.go new file mode 100644 index 000000000..14e81ffa5 --- /dev/null +++ b/api/dashboard/delete.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/dashboard" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation DELETE /api/v1/dashboards/{dashboard} dashboards DeleteDashboard +// +// Delete a dashboard in the configured backend +// +// --- +// produces: +// - application/json +// parameters: +// - in: path +// name: dashboard +// description: id of the dashboard +// required: true +// type: string +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully deleted dashboard +// schema: +// type: string +// '401': +// description: Unauthorized to delete dashboard +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Server error when deleting dashboard +// schema: +// "$ref": "#/definitions/Error" + +// DeleteDashboard represents the API handler to remove +// a dashboard from the configured backend. +func DeleteDashboard(c *gin.Context) { + // capture middleware values + d := dashboard.Retrieve(c) + u := user.Retrieve(c) + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "dashboard": d.GetID(), + "user": u.GetName(), + }).Infof("deleting dashboard %s", d.GetID()) + + if !isAdmin(d, u) { + retErr := fmt.Errorf("unable to delete dashboard %s: user is not an admin", d.GetID()) + + util.HandleError(c, http.StatusUnauthorized, retErr) + + return + } + + err := database.FromContext(c).DeleteDashboard(c, d) + if err != nil { + retErr := fmt.Errorf("error while deleting dashboard %s: %w", d.GetID(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + c.JSON(http.StatusOK, fmt.Sprintf("dashboard %s deleted", d.GetName())) +} + +// isAdmin is a helper function that iterates through the dashboard admins +// and confirms if the user is in the slice. +func isAdmin(d *types.Dashboard, u *types.User) bool { + for _, admin := range d.GetAdmins() { + if admin.GetID() == u.GetID() { + return true + } + } + + return false +} diff --git a/api/dashboard/get.go b/api/dashboard/get.go new file mode 100644 index 000000000..f36f92ee4 --- /dev/null +++ b/api/dashboard/get.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/dashboard" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation GET /api/v1/dashboards/{dashboard} dashboards GetDashboard +// +// Get a dashboard in the configured backend +// +// --- +// produces: +// - application/json +// parameters: +// - in: path +// name: dashboard +// description: Dashboard id to retrieve +// required: true +// type: string +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully retrieved dashboard +// type: json +// schema: +// "$ref": "#/definitions/Dashboard" +// '401': +// description: Unauthorized to retrieve dashboard +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Server error when retrieving dashboard +// schema: +// "$ref": "#/definitions/Error" + +// GetDashboard represents the API handler to capture +// a dashboard for a repo from the configured backend. +func GetDashboard(c *gin.Context) { + // capture middleware values + d := dashboard.Retrieve(c) + u := user.Retrieve(c) + + var err error + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "dashboard": d.GetID(), + "user": u.GetName(), + }).Infof("reading dashboard %s", d.GetID()) + + // initialize DashCard and set dashboard to the dashboard info pulled from database + dashboard := new(types.DashCard) + dashboard.Dashboard = d + + // build RepoPartials referenced in the dashboard + dashboard.Repos, err = buildRepoPartials(c, d.GetRepos()) + if err != nil { + util.HandleError(c, http.StatusInternalServerError, err) + + return + } + + c.JSON(http.StatusOK, dashboard) +} + +// buildRepoPartials is a helper function which takes the dashboard repo list and builds +// a list of RepoPartials with information about the associated repository and its latest +// five builds. +func buildRepoPartials(c context.Context, repos []*types.DashboardRepo) ([]types.RepoPartial, error) { + var result []types.RepoPartial + + for _, r := range repos { + repo := types.RepoPartial{} + + // fetch repo from database + dbRepo, err := database.FromContext(c).GetRepo(c, r.GetID()) + if err != nil { + return nil, fmt.Errorf("unable to get repo %s for dashboard: %w", r.GetName(), err) + } + + // set values for RepoPartial + repo.Org = dbRepo.GetOrg() + repo.Name = dbRepo.GetName() + repo.Counter = dbRepo.GetCounter() + + // list last 5 builds for repo given the branch and event filters + builds, err := database.FromContext(c).ListBuildsForDashboardRepo(c, dbRepo, r.GetBranches(), r.GetEvents()) + if err != nil { + return nil, fmt.Errorf("unable to list builds for repo %s in dashboard: %w", dbRepo.GetFullName(), err) + } + + bPartials := []types.BuildPartial{} + + // populate BuildPartials with info from builds list + for _, build := range builds { + bPartial := types.BuildPartial{ + Number: build.GetNumber(), + Status: build.GetStatus(), + Started: build.GetStarted(), + Finished: build.GetFinished(), + Sender: build.GetSender(), + Branch: build.GetBranch(), + Event: build.GetEvent(), + Link: build.GetLink(), + } + + bPartials = append(bPartials, bPartial) + } + + repo.Builds = bPartials + + result = append(result, repo) + } + + return result, nil +} diff --git a/api/dashboard/list_user.go b/api/dashboard/list_user.go new file mode 100644 index 000000000..656704358 --- /dev/null +++ b/api/dashboard/list_user.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation GET /api/v1/user/dashboards dashboards ListUserDashboards +// +// Get all dashboards for the current user in the configured backend +// +// --- +// produces: +// - application/json +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully retrieved user dashboards +// type: json +// schema: +// "$ref": "#/definitions/Dashboard" +// '400': +// description: Bad request to retrieve user dashboards +// schema: +// "$ref": "#/definitions/Error" +// '401': +// description: Unauthorized to retrieve user dashboards +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Server error when retrieving user dashboards +// schema: +// "$ref": "#/definitions/Error" + +// ListUserDashboards represents the API handler to capture a list +// of dashboards for a user from the configured backend. +func ListUserDashboards(c *gin.Context) { + // capture middleware values + u := user.Retrieve(c) + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "user": u.GetName(), + }).Infof("listing dashboards for user %s", u.GetName()) + + var dashCards []types.DashCard + + // iterate through user dashboards and build a list of DashCards + for _, dashboard := range u.GetDashboards() { + dashCard := types.DashCard{} + + d, err := database.FromContext(c).GetDashboard(c, dashboard) + if err != nil { + // check if the query returned a record not found error + if errors.Is(err, gorm.ErrRecordNotFound) { + d = new(types.Dashboard) + d.SetID(dashboard) + + dashCard.Dashboard = d + // if user dashboard has been deleted, append empty dashboard + // to set and continue + dashCards = append(dashCards, dashCard) + + continue + } + + retErr := fmt.Errorf("unable to get dashboard %s: %w", dashboard, err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + dashCard.Dashboard = d + + dashCard.Repos, err = buildRepoPartials(c, d.GetRepos()) + if err != nil { + util.HandleError(c, http.StatusInternalServerError, err) + + return + } + + dashCards = append(dashCards, dashCard) + } + + c.JSON(http.StatusOK, dashCards) +} diff --git a/api/dashboard/update.go b/api/dashboard/update.go new file mode 100644 index 000000000..7958ef72d --- /dev/null +++ b/api/dashboard/update.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/dashboard" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// swagger:operation PUT /api/v1/dashboards/{dashboard} dashboards UpdateDashboard +// +// Update a dashboard for the configured backend +// +// --- +// produces: +// - application/json +// parameters: +// - name: dashboard +// in: path +// description: ID of the dashboard +// required: true +// type: string +// - name: body +// in: body +// description: Payload containing the dashboard to update +// required: true +// schema: +// $ref: '#/definitions/Dashboard' +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully updated dashboard +// schema: +// "$ref": "#/definitions/Dashboard" +// '400': +// description: Bad request when updating dashboard +// schema: +// "$ref": "#/definitions/Error" +// '401': +// description: Unauthorized to update dashboard +// schema: +// "$ref": "#/definitions/Error" +// '404': +// description: Unable to find dashboard +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Error while updating dashboard +// schema: +// "$ref": "#/definitions/Error" + +// UpdateDashboard represents the API handler to update +// a dashboard in the configured backend. +func UpdateDashboard(c *gin.Context) { + // capture middleware values + d := dashboard.Retrieve(c) + u := user.Retrieve(c) + + if !isAdmin(d, u) { + retErr := fmt.Errorf("unable to update dashboard %s: user is not an admin", d.GetID()) + + util.HandleError(c, http.StatusUnauthorized, retErr) + + return + } + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "dashboard": d.GetID(), + }).Infof("updating dashboard %s", d.GetID()) + + // capture body from API request + input := new(types.Dashboard) + + err := c.Bind(input) + if err != nil { + retErr := fmt.Errorf("unable to decode JSON for dashboard %s: %w", d.GetID(), err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + if input.GetName() != "" { + // update name if defined + d.SetName(input.GetName()) + } + + // validate admin set if supplied + if len(input.GetAdmins()) > 0 { + admins, err := createAdminSet(c, u, input.GetAdmins()) + if err != nil { + util.HandleError(c, http.StatusBadRequest, err) + + return + } + + d.SetAdmins(admins) + } + + // set the updated by field using claims + d.SetUpdatedBy(u.GetName()) + + // validate repo set if supplied + if len(input.GetRepos()) > 0 { + // validate supplied repo list + err = validateRepoSet(c, input.GetRepos()) + if err != nil { + util.HandleError(c, http.StatusBadRequest, err) + + return + } + + d.SetRepos(input.GetRepos()) + } + + // update the dashboard within the database + d, err = database.FromContext(c).UpdateDashboard(c, d) + if err != nil { + retErr := fmt.Errorf("unable to update dashboard %s: %w", input.GetID(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + c.JSON(http.StatusOK, d) +} diff --git a/api/types/dashboard.go b/api/types/dashboard.go new file mode 100644 index 000000000..053942daf --- /dev/null +++ b/api/types/dashboard.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "fmt" +) + +// RepoPartial is an API type that holds all relevant information +// for a repository attached to a dashboard. +type RepoPartial struct { + Org string `json:"org,omitempty"` + Name string `json:"name,omitempty"` + Counter int `json:"counter,omitempty"` + Builds []BuildPartial `json:"builds,omitempty"` +} + +// BuildPartial is an API type that holds all relevant information +// for a build attached to a RepoPartial. +type BuildPartial struct { + Number int `json:"number,omitempty"` + Started int64 `json:"started,omitempty"` + Finished int64 `json:"finished,omitempty"` + Sender string `json:"sender,omitempty"` + Status string `json:"status,omitempty"` + Event string `json:"event,omitempty"` + Branch string `json:"branch,omitempty"` + Link string `json:"link,omitempty"` +} + +// DashCard is an API type that holds the dashboard information as +// well as a list of RepoPartials attached to the dashboard. +type DashCard struct { + Dashboard *Dashboard `json:"dashboard,omitempty"` + Repos []RepoPartial `json:"repos,omitempty"` +} + +// Dashboard is the API representation of a dashboard. +// +// swagger:model Dashboard +type Dashboard struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + CreatedAt *int64 `json:"created_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + UpdatedAt *int64 `json:"updated_at,omitempty"` + UpdatedBy *string `json:"updated_by,omitempty"` + Admins *[]*User `json:"admins,omitempty"` + Repos *[]*DashboardRepo `json:"repos,omitempty"` +} + +// GetID returns the ID field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetID() string { + // return zero value if Dashboard type or ID field is nil + if d == nil || d.ID == nil { + return "" + } + + return *d.ID +} + +// GetName returns the Name field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetName() string { + // return zero value if Dashboard type or Name field is nil + if d == nil || d.Name == nil { + return "" + } + + return *d.Name +} + +// GetCreatedAt returns the CreatedAt field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetCreatedAt() int64 { + // return zero value if Dashboard type or CreatedAt field is nil + if d == nil || d.CreatedAt == nil { + return 0 + } + + return *d.CreatedAt +} + +// GetCreatedBy returns the CreatedBy field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetCreatedBy() string { + // return zero value if Dashboard type or CreatedBy field is nil + if d == nil || d.CreatedBy == nil { + return "" + } + + return *d.CreatedBy +} + +// GetUpdatedAt returns the UpdatedAt field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetUpdatedAt() int64 { + // return zero value if Dashboard type or UpdatedAt field is nil + if d == nil || d.UpdatedAt == nil { + return 0 + } + + return *d.UpdatedAt +} + +// GetUpdatedBy returns the UpdatedBy field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetUpdatedBy() string { + // return zero value if Dashboard type or UpdatedBy field is nil + if d == nil || d.UpdatedBy == nil { + return "" + } + + return *d.UpdatedBy +} + +// GetAdmins returns the Admins field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetAdmins() []*User { + // return zero value if Dashboard type or Admins field is nil + if d == nil || d.Admins == nil { + return []*User{} + } + + return *d.Admins +} + +// GetRepos returns the Repos field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *Dashboard) GetRepos() []*DashboardRepo { + // return zero value if Dashboard type or Repos field is nil + if d == nil || d.Repos == nil { + return []*DashboardRepo{} + } + + return *d.Repos +} + +// SetID sets the ID field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetID(v string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.ID = &v +} + +// SetName sets the Name field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetName(v string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.Name = &v +} + +// SetCreatedAt sets the CreatedAt field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetCreatedAt(v int64) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.CreatedAt = &v +} + +// SetCreatedBy sets the CreatedBy field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetCreatedBy(v string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.CreatedBy = &v +} + +// SetUpdatedAt sets the UpdatedAt field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetUpdatedAt(v int64) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.UpdatedAt = &v +} + +// SetUpdatedBy sets the UpdatedBy field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetUpdatedBy(v string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.UpdatedBy = &v +} + +// SetAdmins sets the Admins field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetAdmins(v []*User) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.Admins = &v +} + +// SetRepos sets the Repos field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *Dashboard) SetRepos(v []*DashboardRepo) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.Repos = &v +} + +// String implements the Stringer interface for the Dashboard type. +func (d *Dashboard) String() string { + return fmt.Sprintf(`{ + Name: %s, + ID: %s, + Admins: %v, + CreatedAt: %d, + CreatedBy: %s, + UpdatedAt: %d, + UpdatedBy: %s, + Repos: %v, +}`, + d.GetName(), + d.GetID(), + d.GetAdmins(), + d.GetCreatedAt(), + d.GetCreatedBy(), + d.GetUpdatedAt(), + d.GetUpdatedBy(), + d.GetRepos(), + ) +} diff --git a/api/types/dashboard_repo.go b/api/types/dashboard_repo.go new file mode 100644 index 000000000..4e1faa2bc --- /dev/null +++ b/api/types/dashboard_repo.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "fmt" +) + +// DashboardRepo is the API representation of a repo belonging to a Dashboard. +// +// swagger:model DashboardRepo +type DashboardRepo struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Branches *[]string `json:"branches,omitempty"` + Events *[]string `json:"events,omitempty"` +} + +// GetID returns the ID field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *DashboardRepo) GetID() int64 { + // return zero value if Dashboard type or ID field is nil + if d == nil || d.ID == nil { + return 0 + } + + return *d.ID +} + +// GetName returns the Name field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *DashboardRepo) GetName() string { + // return zero value if Dashboard type or ID field is nil + if d == nil || d.Name == nil { + return "" + } + + return *d.Name +} + +// GetBranches returns the Branches field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *DashboardRepo) GetBranches() []string { + // return zero value if Dashboard type or Branches field is nil + if d == nil || d.Branches == nil { + return []string{} + } + + return *d.Branches +} + +// GetEvents returns the Events field. +// +// When the provided Dashboard type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (d *DashboardRepo) GetEvents() []string { + // return zero value if Dashboard type or Events field is nil + if d == nil || d.Events == nil { + return []string{} + } + + return *d.Events +} + +// SetID sets the ID field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *DashboardRepo) SetID(v int64) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.ID = &v +} + +// SetName sets the Name field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *DashboardRepo) SetName(v string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.Name = &v +} + +// SetBranches sets the Branches field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *DashboardRepo) SetBranches(v []string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.Branches = &v +} + +// SetEvents sets the Events field. +// +// When the provided Dashboard type is nil, it +// will set nothing and immediately return. +func (d *DashboardRepo) SetEvents(v []string) { + // return if Dashboard type is nil + if d == nil { + return + } + + d.Events = &v +} + +// String implements the Stringer interface for the Dashboard type. +func (d *DashboardRepo) String() string { + return fmt.Sprintf(`{ + Name: %s, + ID: %d, + Branches: %v, + Events: %v, +}`, + d.GetName(), + d.GetID(), + d.GetBranches(), + d.GetEvents(), + ) +} diff --git a/api/types/dashboard_repo_test.go b/api/types/dashboard_repo_test.go new file mode 100644 index 000000000..892b698e3 --- /dev/null +++ b/api/types/dashboard_repo_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "fmt" + "reflect" + "testing" +) + +func TestTypes_DashboardRepo_Getters(t *testing.T) { + // setup tests + tests := []struct { + dashboardRepo *DashboardRepo + want *DashboardRepo + }{ + { + dashboardRepo: testDashboardRepo(), + want: testDashboardRepo(), + }, + { + dashboardRepo: new(DashboardRepo), + want: new(DashboardRepo), + }, + } + + // run tests + for _, test := range tests { + if test.dashboardRepo.GetID() != test.want.GetID() { + t.Errorf("GetID is %v, want %v", test.dashboardRepo.GetID(), test.want.GetID()) + } + + if test.dashboardRepo.GetName() != test.want.GetName() { + t.Errorf("GetName is %v, want %v", test.dashboardRepo.GetName(), test.want.GetName()) + } + + if !reflect.DeepEqual(test.dashboardRepo.GetBranches(), test.want.GetBranches()) { + t.Errorf("GetBranches is %v, want %v", test.dashboardRepo.GetBranches(), test.want.GetBranches()) + } + + if !reflect.DeepEqual(test.dashboardRepo.GetEvents(), test.want.GetEvents()) { + t.Errorf("GetEvents is %v, want %v", test.dashboardRepo.GetEvents(), test.want.GetEvents()) + } + } +} + +func TestTypes_DashboardRepo_Setters(t *testing.T) { + // setup types + var d *DashboardRepo + + // setup tests + tests := []struct { + dashboardRepo *DashboardRepo + want *DashboardRepo + }{ + { + dashboardRepo: testDashboardRepo(), + want: testDashboardRepo(), + }, + { + dashboardRepo: d, + want: new(DashboardRepo), + }, + } + + // run tests + for _, test := range tests { + test.dashboardRepo.SetID(test.want.GetID()) + test.dashboardRepo.SetName(test.want.GetName()) + test.dashboardRepo.SetBranches(test.want.GetBranches()) + test.dashboardRepo.SetEvents(test.want.GetEvents()) + + if test.dashboardRepo.GetID() != test.want.GetID() { + t.Errorf("SetID is %v, want %v", test.dashboardRepo.GetID(), test.want.GetID()) + } + + if test.dashboardRepo.GetName() != test.want.GetName() { + t.Errorf("SetName is %v, want %v", test.dashboardRepo.GetName(), test.want.GetName()) + } + + if !reflect.DeepEqual(test.dashboardRepo.GetBranches(), test.want.GetBranches()) { + t.Errorf("SetBranches is %v, want %v", test.dashboardRepo.GetBranches(), test.want.GetBranches()) + } + + if !reflect.DeepEqual(test.dashboardRepo.GetEvents(), test.want.GetEvents()) { + t.Errorf("SetEvents is %v, want %v", test.dashboardRepo.GetEvents(), test.want.GetEvents()) + } + } +} + +func TestTypes_DashboardRepo_String(t *testing.T) { + // setup types + d := testDashboardRepo() + + want := fmt.Sprintf(`{ + Name: %s, + ID: %d, + Branches: %v, + Events: %v, +}`, + d.GetName(), + d.GetID(), + d.GetBranches(), + d.GetEvents(), + ) + + // run test + got := d.String() + + if !reflect.DeepEqual(got, want) { + t.Errorf("String is %v, want %v", got, want) + } +} + +// testDashboardRepo is a test helper function to create a DashboardRepo +// type with all fields set to a fake value. +func testDashboardRepo() *DashboardRepo { + d := new(DashboardRepo) + + d.SetName("go-vela/server") + d.SetID(1) + d.SetBranches([]string{"main"}) + d.SetEvents([]string{"push", "tag"}) + + return d +} diff --git a/api/types/dashboard_test.go b/api/types/dashboard_test.go new file mode 100644 index 000000000..29f8a283c --- /dev/null +++ b/api/types/dashboard_test.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "fmt" + "reflect" + "testing" +) + +func TestTypes_Dashboard_Getters(t *testing.T) { + // setup tests + tests := []struct { + dashboard *Dashboard + want *Dashboard + }{ + { + dashboard: testDashboard(), + want: testDashboard(), + }, + { + dashboard: new(Dashboard), + want: new(Dashboard), + }, + } + + // run tests + for _, test := range tests { + if test.dashboard.GetID() != test.want.GetID() { + t.Errorf("GetID is %v, want %v", test.dashboard.GetID(), test.want.GetID()) + } + + if test.dashboard.GetName() != test.want.GetName() { + t.Errorf("GetName is %v, want %v", test.dashboard.GetName(), test.want.GetName()) + } + + if !reflect.DeepEqual(test.dashboard.GetAdmins(), test.want.GetAdmins()) { + t.Errorf("GetAdmins is %v, want %v", test.dashboard.GetAdmins(), test.want.GetAdmins()) + } + + if test.dashboard.GetCreatedAt() != test.want.GetCreatedAt() { + t.Errorf("GetCreatedAt is %v, want %v", test.dashboard.GetCreatedAt(), test.want.GetCreatedAt()) + } + + if test.dashboard.GetCreatedBy() != test.want.GetCreatedBy() { + t.Errorf("GetCreatedBy is %v, want %v", test.dashboard.GetCreatedBy(), test.want.GetCreatedBy()) + } + + if test.dashboard.GetUpdatedAt() != test.want.GetUpdatedAt() { + t.Errorf("GetUpdatedAt is %v, want %v", test.dashboard.GetUpdatedAt(), test.want.GetUpdatedAt()) + } + + if test.dashboard.GetUpdatedBy() != test.want.GetUpdatedBy() { + t.Errorf("GetUpdatedBy is %v, want %v", test.dashboard.GetUpdatedBy(), test.want.GetUpdatedBy()) + } + + if !reflect.DeepEqual(test.dashboard.GetRepos(), test.want.GetRepos()) { + t.Errorf("GetRepos is %v, want %v", test.dashboard.GetRepos(), test.want.GetRepos()) + } + } +} + +func TestTypes_Dashboard_Setters(t *testing.T) { + // setup types + var d *Dashboard + + // setup tests + tests := []struct { + dashboard *Dashboard + want *Dashboard + }{ + { + dashboard: testDashboard(), + want: testDashboard(), + }, + { + dashboard: d, + want: new(Dashboard), + }, + } + + // run tests + for _, test := range tests { + test.dashboard.SetID(test.want.GetID()) + test.dashboard.SetName(test.want.GetName()) + test.dashboard.SetAdmins(test.want.GetAdmins()) + test.dashboard.SetCreatedAt(test.want.GetCreatedAt()) + test.dashboard.SetCreatedBy(test.want.GetCreatedBy()) + test.dashboard.SetUpdatedAt(test.want.GetUpdatedAt()) + test.dashboard.SetUpdatedBy(test.want.GetUpdatedBy()) + test.dashboard.SetRepos(test.want.GetRepos()) + + if test.dashboard.GetID() != test.want.GetID() { + t.Errorf("SetID is %v, want %v", test.dashboard.GetID(), test.want.GetID()) + } + + if test.dashboard.GetName() != test.want.GetName() { + t.Errorf("SetName is %v, want %v", test.dashboard.GetName(), test.want.GetName()) + } + + if !reflect.DeepEqual(test.dashboard.GetAdmins(), test.want.GetAdmins()) { + t.Errorf("SetAdmins is %v, want %v", test.dashboard.GetAdmins(), test.want.GetAdmins()) + } + + if test.dashboard.GetCreatedAt() != test.want.GetCreatedAt() { + t.Errorf("SetCreatedAt is %v, want %v", test.dashboard.GetCreatedAt(), test.want.GetCreatedAt()) + } + + if test.dashboard.GetCreatedBy() != test.want.GetCreatedBy() { + t.Errorf("SetCreatedBy is %v, want %v", test.dashboard.GetCreatedBy(), test.want.GetCreatedBy()) + } + + if test.dashboard.GetUpdatedAt() != test.want.GetUpdatedAt() { + t.Errorf("SetUpdatedAt is %v, want %v", test.dashboard.GetUpdatedAt(), test.want.GetUpdatedAt()) + } + + if test.dashboard.GetUpdatedBy() != test.want.GetUpdatedBy() { + t.Errorf("SetUpdatedBy is %v, want %v", test.dashboard.GetUpdatedBy(), test.want.GetUpdatedBy()) + } + + if !reflect.DeepEqual(test.dashboard.GetRepos(), test.want.GetRepos()) { + t.Errorf("SetRepos is %v, want %v", test.dashboard.GetRepos(), test.want.GetRepos()) + } + } +} + +func TestTypes_Dashboard_String(t *testing.T) { + // setup types + d := testDashboard() + + want := fmt.Sprintf(`{ + Name: %s, + ID: %s, + Admins: %v, + CreatedAt: %d, + CreatedBy: %s, + UpdatedAt: %d, + UpdatedBy: %s, + Repos: %v, +}`, + d.GetName(), + d.GetID(), + d.GetAdmins(), + d.GetCreatedAt(), + d.GetCreatedBy(), + d.GetUpdatedAt(), + d.GetUpdatedBy(), + d.GetRepos(), + ) + + // run test + got := d.String() + + if !reflect.DeepEqual(got, want) { + t.Errorf("String is %v, want %v", got, want) + } +} + +// testDashboard is a test helper function to create a Dashboard +// type with all fields set to a fake value. +func testDashboard() *Dashboard { + d := new(Dashboard) + + d.SetID("123-abc") + d.SetName("vela") + d.SetAdmins([]*User{testUser()}) + d.SetCreatedAt(1) + d.SetCreatedBy("octocat") + d.SetUpdatedAt(2) + d.SetUpdatedBy("octokitty") + d.SetRepos([]*DashboardRepo{testDashboardRepo()}) + + return d +} diff --git a/api/types/events.go b/api/types/events.go index 589f4de46..2dc6f1e28 100644 --- a/api/types/events.go +++ b/api/types/events.go @@ -9,7 +9,7 @@ import ( "github.com/go-vela/types/constants" ) -// Events is the library representation of the various events that generate a +// Events is the API representation of the various events that generate a // webhook from the SCM. type Events struct { Push *actions.Push `json:"push"` diff --git a/api/types/user.go b/api/types/user.go index 50e450030..2bad2f3ea 100644 --- a/api/types/user.go +++ b/api/types/user.go @@ -19,6 +19,7 @@ type User struct { Favorites *[]string `json:"favorites,omitempty"` Active *bool `json:"active,omitempty"` Admin *bool `json:"admin,omitempty"` + Dashboards *[]string `json:"dashboards,omitempty"` } // Sanitize creates a duplicate of the User without the token values. @@ -139,6 +140,19 @@ func (u *User) GetFavorites() []string { return *u.Favorites } +// GetDashboards returns the Dashboards field. +// +// When the provided User type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (u *User) GetDashboards() []string { + // return zero value if User type or Favorites field is nil + if u == nil || u.Dashboards == nil { + return []string{} + } + + return *u.Dashboards +} + // SetID sets the ID field. // // When the provided User type is nil, it @@ -230,11 +244,25 @@ func (u *User) SetFavorites(v []string) { u.Favorites = &v } +// SetDashboard sets the Dashboard field. +// +// When the provided User type is nil, it +// will set nothing and immediately return. +func (u *User) SetDashboards(v []string) { + // return if User type is nil + if u == nil { + return + } + + u.Dashboards = &v +} + // String implements the Stringer interface for the User type. func (u *User) String() string { return fmt.Sprintf(`{ Active: %t, Admin: %t, + Dashboards: %s, Favorites: %s, ID: %d, Name: %s, @@ -242,6 +270,7 @@ func (u *User) String() string { }`, u.GetActive(), u.GetAdmin(), + u.GetDashboards(), u.GetFavorites(), u.GetID(), u.GetName(), diff --git a/api/types/user_test.go b/api/types/user_test.go index d2db92020..1da7acb2f 100644 --- a/api/types/user_test.go +++ b/api/types/user_test.go @@ -91,6 +91,10 @@ func TestTypes_User_Getters(t *testing.T) { if test.user.GetAdmin() != test.want.GetAdmin() { t.Errorf("GetAdmin is %v, want %v", test.user.GetAdmin(), test.want.GetAdmin()) } + + if !reflect.DeepEqual(test.user.GetDashboards(), test.want.GetDashboards()) { + t.Errorf("GetDashboards is %v, want %v", test.user.GetDashboards(), test.want.GetDashboards()) + } } } @@ -122,6 +126,7 @@ func TestTypes_User_Setters(t *testing.T) { test.user.SetFavorites(test.want.GetFavorites()) test.user.SetActive(test.want.GetActive()) test.user.SetAdmin(test.want.GetAdmin()) + test.user.SetDashboards(test.want.GetDashboards()) if test.user.GetID() != test.want.GetID() { t.Errorf("SetID is %v, want %v", test.user.GetID(), test.want.GetID()) @@ -150,6 +155,10 @@ func TestTypes_User_Setters(t *testing.T) { if test.user.GetAdmin() != test.want.GetAdmin() { t.Errorf("SetAdmin is %v, want %v", test.user.GetAdmin(), test.want.GetAdmin()) } + + if !reflect.DeepEqual(test.user.GetDashboards(), test.want.GetDashboards()) { + t.Errorf("SetDashboards is %v, want %v", test.user.GetDashboards(), test.want.GetDashboards()) + } } } @@ -160,6 +169,7 @@ func TestTypes_User_String(t *testing.T) { want := fmt.Sprintf(`{ Active: %t, Admin: %t, + Dashboards: %s, Favorites: %s, ID: %d, Name: %s, @@ -167,6 +177,7 @@ func TestTypes_User_String(t *testing.T) { }`, u.GetActive(), u.GetAdmin(), + u.GetDashboards(), u.GetFavorites(), u.GetID(), u.GetName(), @@ -192,6 +203,7 @@ func testUser() *User { u.SetFavorites([]string{"github/octocat"}) u.SetActive(true) u.SetAdmin(false) + u.SetDashboards([]string{"45bcf19b-c151-4e2d-b8c6-80a62ba2eae7", "ba657dab-bc6e-421f-9188-86272bd0069a"}) return u } diff --git a/api/user/update.go b/api/user/update.go index caec2ac08..3983abd0f 100644 --- a/api/user/update.go +++ b/api/user/update.go @@ -107,6 +107,11 @@ func UpdateUser(c *gin.Context) { u.SetFavorites(input.GetFavorites()) } + if input.Dashboards != nil { + // update dashboards if set + u.SetDashboards(input.GetDashboards()) + } + // send API call to update the user u, err = database.FromContext(c).UpdateUser(ctx, u) if err != nil { diff --git a/api/user/update_current.go b/api/user/update_current.go index 792105a48..dec97ba71 100644 --- a/api/user/update_current.go +++ b/api/user/update_current.go @@ -81,6 +81,12 @@ func UpdateCurrentUser(c *gin.Context) { u.SetFavorites(input.GetFavorites()) } + // update user fields if provided + if input.Dashboards != nil { + // update dashboards if set + u.SetDashboards(input.GetDashboards()) + } + // send API call to update the user u, err = database.FromContext(c).UpdateUser(ctx, u) if err != nil { diff --git a/constants/limit.go b/constants/limit.go new file mode 100644 index 000000000..046b6c19b --- /dev/null +++ b/constants/limit.go @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +package constants + +// Limits and constraints. +const ( + // DashboardAdminMaxSize defines the maximum size in characters for dashboard admins. + DashboardAdminMaxSize = 5000 +) diff --git a/constants/table.go b/constants/table.go new file mode 100644 index 000000000..6d9ff76e8 --- /dev/null +++ b/constants/table.go @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +package constants + +// Database tables. +const ( + // TableDashboard defines the table type for the database dashboards table. + TableDashboard = "dashboards" +) diff --git a/database/build/count_org_test.go b/database/build/count_org_test.go index fb6461261..635e7bd66 100644 --- a/database/build/count_org_test.go +++ b/database/build/count_org_test.go @@ -11,7 +11,6 @@ import ( "github.com/go-vela/server/database/repo" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" ) func TestBuild_Engine_CountBuildsForOrg(t *testing.T) { @@ -78,7 +77,7 @@ func TestBuild_Engine_CountBuildsForOrg(t *testing.T) { t.Errorf("unable to create test build for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.Repo{}) + err = _sqlite.client.AutoMigrate(&repo.Repo{}) if err != nil { t.Errorf("unable to create repo table for sqlite: %v", err) } diff --git a/database/build/interface.go b/database/build/interface.go index 18c85d194..03c6918b7 100644 --- a/database/build/interface.go +++ b/database/build/interface.go @@ -53,6 +53,8 @@ type BuildInterface interface { ListBuilds(context.Context) ([]*library.Build, error) // ListBuildsForOrg defines a function that gets a list of builds by org name. ListBuildsForOrg(context.Context, string, map[string]interface{}, int, int) ([]*library.Build, int64, error) + // ListBuildsForDashboardRepo defines a function that gets a list of builds based on dashboard filters. + ListBuildsForDashboardRepo(context.Context, *api.Repo, []string, []string) ([]*library.Build, error) // ListBuildsForRepo defines a function that gets a list of builds by repo ID. ListBuildsForRepo(context.Context, *api.Repo, map[string]interface{}, int64, int64, int, int) ([]*library.Build, int64, error) // ListPendingAndRunningBuilds defines a function that gets a list of pending and running builds. diff --git a/database/build/list_dashboard.go b/database/build/list_dashboard.go new file mode 100644 index 000000000..d9f772873 --- /dev/null +++ b/database/build/list_dashboard.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "context" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/database" + "github.com/go-vela/types/library" +) + +// ListBuildsForDashboardRepo gets a list of builds by repo ID from the database. +func (e *engine) ListBuildsForDashboardRepo(ctx context.Context, r *api.Repo, branches, events []string) ([]*library.Build, error) { + e.logger.WithFields(logrus.Fields{ + "org": r.GetOrg(), + "repo": r.GetName(), + }).Tracef("listing builds for repo %s from the database", r.GetFullName()) + + // variables to store query results and return values + b := new([]database.Build) + builds := []*library.Build{} + + query := e.client.Table(constants.TableBuild).Where("repo_id = ?", r.GetID()) + + if len(branches) > 0 { + query = query.Where("branch IN (?)", branches) + } + + if len(events) > 0 { + query = query.Where("event IN (?)", events) + } + + err := query. + Order("number DESC"). + Limit(5). + Find(&b). + Error + if err != nil { + return nil, err + } + + // iterate through all query results + for _, build := range *b { + // https://golang.org/doc/faq#closures_and_goroutines + tmp := build + + // convert query result to library type + // + // https://pkg.go.dev/github.com/go-vela/types/database#Build.ToLibrary + builds = append(builds, tmp.ToLibrary()) + } + + return builds, nil +} diff --git a/database/build/list_dashboard_test.go b/database/build/list_dashboard_test.go new file mode 100644 index 000000000..e73242efb --- /dev/null +++ b/database/build/list_dashboard_test.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +package build + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/go-cmp/cmp" + + "github.com/go-vela/types/library" +) + +func TestBuild_Engine_ListBuildsForDashboardRepo(t *testing.T) { + // setup types + _buildOne := testBuild() + _buildOne.SetID(1) + _buildOne.SetRepoID(1) + _buildOne.SetNumber(1) + _buildOne.SetDeployPayload(nil) + _buildOne.SetCreated(1) + _buildOne.SetEvent("push") + _buildOne.SetBranch("main") + + _buildTwo := testBuild() + _buildTwo.SetID(2) + _buildTwo.SetRepoID(1) + _buildTwo.SetNumber(2) + _buildTwo.SetDeployPayload(nil) + _buildTwo.SetCreated(2) + _buildTwo.SetEvent("pull_request") + _buildTwo.SetBranch("main") + + _repo := testRepo() + _repo.SetID(1) + _repo.SetHash("baz") + _repo.SetOrg("foo") + _repo.SetName("bar") + _repo.SetFullName("foo/bar") + _repo.SetVisibility("public") + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // create expected query result in mock + _rows := sqlmock.NewRows( + []string{"id", "repo_id", "pipeline_id", "number", "parent", "event", "event_action", "status", "error", "enqueued", "created", "started", "finished", "deploy", "deploy_payload", "clone", "source", "title", "message", "commit", "sender", "author", "email", "link", "branch", "ref", "base_ref", "head_ref", "host", "runtime", "distribution", "approved_at", "approved_by", "timestamp"}). + AddRow(2, 1, nil, 2, 0, "pull_request", "", "", "", 0, 2, 0, 0, "", nil, "", "", "", "", "", "", "", "", "", "main", "", "", "", "", "", "", 0, "", 0). + AddRow(1, 1, nil, 1, 0, "push", "", "", "", 0, 1, 0, 0, "", nil, "", "", "", "", "", "", "", "", "", "main", "", "", "", "", "", "", 0, "", 0) + + // ensure the mock expects the query + _mock.ExpectQuery(`SELECT * FROM "builds" WHERE repo_id = $1 AND branch IN ($2) AND event IN ($3,$4) ORDER BY number DESC LIMIT $5`).WithArgs(1, "main", "push", "pull_request", 5).WillReturnRows(_rows) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + _, err := _sqlite.CreateBuild(context.TODO(), _buildOne) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + _, err = _sqlite.CreateBuild(context.TODO(), _buildTwo) + if err != nil { + t.Errorf("unable to create test build for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *engine + want []*library.Build + }{ + { + failure: false, + name: "postgres", + database: _postgres, + want: []*library.Build{_buildTwo, _buildOne}, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + want: []*library.Build{_buildTwo, _buildOne}, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.database.ListBuildsForDashboardRepo(context.TODO(), _repo, []string{"main"}, []string{"push", "pull_request"}) + + if test.failure { + if err == nil { + t.Errorf("ListBuildsForRepo for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("ListBuildsForRepo for %s returned err: %v", test.name, err) + } + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("GetDashboard mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/database/build/list_org_test.go b/database/build/list_org_test.go index 925acbf96..488cf1e33 100644 --- a/database/build/list_org_test.go +++ b/database/build/list_org_test.go @@ -11,7 +11,6 @@ import ( "github.com/go-vela/server/database/repo" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" "github.com/go-vela/types/library" ) @@ -105,7 +104,7 @@ func TestBuild_Engine_ListBuildsForOrg(t *testing.T) { t.Errorf("unable to create test build for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.Repo{}) + err = _sqlite.client.AutoMigrate(&repo.Repo{}) if err != nil { t.Errorf("unable to create repo table for sqlite: %v", err) } diff --git a/database/build/list_pending_running_repo_test.go b/database/build/list_pending_running_repo_test.go index 76a04b5de..8ea610fb6 100644 --- a/database/build/list_pending_running_repo_test.go +++ b/database/build/list_pending_running_repo_test.go @@ -11,7 +11,6 @@ import ( "github.com/go-vela/server/database/repo" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" "github.com/go-vela/types/library" ) @@ -89,7 +88,7 @@ func TestBuild_Engine_ListPendingAndRunningBuildsForRepo(t *testing.T) { t.Errorf("unable to create test build for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.Repo{}) + err = _sqlite.client.AutoMigrate(&repo.Repo{}) if err != nil { t.Errorf("unable to create repo table for sqlite: %v", err) } diff --git a/database/build/list_pending_running_test.go b/database/build/list_pending_running_test.go index 006c13184..5b427702f 100644 --- a/database/build/list_pending_running_test.go +++ b/database/build/list_pending_running_test.go @@ -11,7 +11,6 @@ import ( "github.com/go-vela/server/database/repo" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" "github.com/go-vela/types/library" ) @@ -76,7 +75,7 @@ func TestBuild_Engine_ListPendingAndRunningBuilds(t *testing.T) { t.Errorf("unable to create test build for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.Repo{}) + err = _sqlite.client.AutoMigrate(&repo.Repo{}) if err != nil { t.Errorf("unable to create repo table for sqlite: %v", err) } diff --git a/database/dashboard/create.go b/database/dashboard/create.go new file mode 100644 index 000000000..5e7ec7887 --- /dev/null +++ b/database/dashboard/create.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// CreateDashboard creates a new dashboard in the database. +func (e *engine) CreateDashboard(ctx context.Context, d *api.Dashboard) (*api.Dashboard, error) { + e.logger.WithFields(logrus.Fields{ + "dashboard": d.GetName(), + }).Tracef("creating dashboard %s in the database", d.GetName()) + + dashboard := FromAPI(d) + + err := dashboard.Validate() + if err != nil { + return nil, err + } + + // send query to the database + result := e.client.Table(constants.TableDashboard).Create(dashboard) + + return dashboard.ToAPI(), result.Error +} diff --git a/database/dashboard/create_test.go b/database/dashboard/create_test.go new file mode 100644 index 000000000..041a39149 --- /dev/null +++ b/database/dashboard/create_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "reflect" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + api "github.com/go-vela/server/api/types" +) + +func TestDashboard_Engine_CreateDashboard(t *testing.T) { + // setup types + _dashRepo := new(api.DashboardRepo) + _dashRepo.SetID(1) + _dashRepo.SetBranches([]string{"main"}) + _dashRepo.SetEvents([]string{"push"}) + _dashRepos := []*api.DashboardRepo{_dashRepo} + + _admin := new(api.User) + _admin.SetID(1) + _admin.SetName("octocat") + _admin.SetActive(true) + _admins := []*api.User{_admin} + + _dashboard := testDashboard() + _dashboard.SetID("c8da1302-07d6-11ea-882f-4893bca275b8") + _dashboard.SetName("dash") + _dashboard.SetCreatedAt(1) + _dashboard.SetCreatedBy("user1") + _dashboard.SetUpdatedAt(1) + _dashboard.SetUpdatedBy("user2") + _dashboard.SetAdmins(_admins) + _dashboard.SetRepos(_dashRepos) + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // create expected result in mock + _rows := sqlmock.NewRows([]string{"id"}).AddRow("c8da1302-07d6-11ea-882f-4893bca275b8") + + // ensure the mock expects the query + _mock.ExpectQuery(`INSERT INTO "dashboards" +("name","created_at","created_by","updated_at","updated_by","admins","repos","id") +VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id"`). + WithArgs("dash", 1, "user1", 1, "user2", `[{"id":1,"name":"octocat","active":true}]`, `[{"id":1,"branches":["main"],"events":["push"]}]`, "c8da1302-07d6-11ea-882f-4893bca275b8"). + WillReturnRows(_rows) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + // setup tests + tests := []struct { + failure bool + name string + database *engine + }{ + { + failure: false, + name: "postgres", + database: _postgres, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.database.CreateDashboard(context.TODO(), _dashboard) + + if test.failure { + if err == nil { + t.Errorf("CreateDashboard for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("CreateDashboard for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(got, _dashboard) { + t.Errorf("CreateDashboard for %s returned %s, want %s", test.name, got, _dashboard) + } + }) + } +} diff --git a/database/dashboard/dashboard.go b/database/dashboard/dashboard.go new file mode 100644 index 000000000..321cfe609 --- /dev/null +++ b/database/dashboard/dashboard.go @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/util" +) + +var ( + // ErrEmptyDashName defines the error type when a + // User type has an empty Name field provided. + ErrEmptyDashName = errors.New("empty dashboard name provided") + + // ErrExceededAdminLimit defines the error type when a + // User type has Admins field provided that exceeds the database limit. + ErrExceededAdminLimit = errors.New("exceeded admins limit") +) + +type ( + // config represents the settings required to create the engine that implements the DashboardService interface. + config struct { + // specifies to skip creating tables and indexes for the Dashboard engine + SkipCreation bool + // specifies the driver for proper popping query + Driver string + } + + // engine represents the dashboard functionality that implements the DashboardService interface. + engine struct { + // engine configuration settings used in dashboard functions + config *config + + ctx context.Context + + // gorm.io/gorm database client used in dashboard functions + // + // https://pkg.go.dev/gorm.io/gorm#DB + client *gorm.DB + + // sirupsen/logrus logger used in dashboard functions + // + // https://pkg.go.dev/github.com/sirupsen/logrus#Entry + logger *logrus.Entry + } + + // Dashboard is the database representation of a dashboard. + Dashboard struct { + ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4()"` + Name sql.NullString `sql:"name"` + CreatedAt sql.NullInt64 `sql:"created_at"` + CreatedBy sql.NullString `sql:"created_by"` + UpdatedAt sql.NullInt64 `sql:"updated_at"` + UpdatedBy sql.NullString `sql:"updated_by"` + Admins AdminsJSON + Repos DashReposJSON + } + + DashReposJSON []*api.DashboardRepo + AdminsJSON []*api.User +) + +// New creates and returns a Vela service for integrating with dashboards in the database. +// +//nolint:revive // ignore returning unexported engine +func New(opts ...EngineOpt) (*engine, error) { + // create new Dashboard engine + e := new(engine) + + // create new fields + e.client = new(gorm.DB) + e.config = new(config) + e.logger = new(logrus.Entry) + + // apply all provided configuration options + for _, opt := range opts { + err := opt(e) + if err != nil { + return nil, err + } + } + + // check if we should skip creating dashboard database objects + if e.config.SkipCreation { + e.logger.Warning("skipping creation of dashboards table and indexes in the database") + + return e, nil + } + + // create the dashboards table + err := e.CreateDashboardTable(e.ctx, e.client.Config.Dialector.Name()) + if err != nil { + return nil, fmt.Errorf("unable to create %s table: %w", constants.TableDashboard, err) + } + + return e, nil +} + +// Value - Implementation of valuer for database/sql for DashReposJSON. +func (r DashReposJSON) Value() (driver.Value, error) { + valueString, err := json.Marshal(r) + return string(valueString), err +} + +// Scan - Implement the database/sql scanner interface for DashReposJSON. +func (r *DashReposJSON) Scan(value interface{}) error { + switch v := value.(type) { + case []byte: + return json.Unmarshal(v, &r) + case string: + return json.Unmarshal([]byte(v), &r) + default: + return fmt.Errorf("wrong type for repos: %T", v) + } +} + +// Value - Implementation of valuer for database/sql for AdminsJSON. +func (a AdminsJSON) Value() (driver.Value, error) { + valueString, err := json.Marshal(a) + return string(valueString), err +} + +// Scan - Implement the database/sql scanner interface for AdminsJSON. +func (a *AdminsJSON) Scan(value interface{}) error { + switch v := value.(type) { + case []byte: + return json.Unmarshal(v, &a) + case string: + return json.Unmarshal([]byte(v), &a) + default: + return fmt.Errorf("wrong type for admins: %T", v) + } +} + +// Nullify ensures the valid flag for +// the sql.Null types are properly set. +// +// When a field within the Dashboard type is the zero +// value for the field, the valid flag is set to +// false causing it to be NULL in the database. +func (d *Dashboard) Nullify() *Dashboard { + if d == nil { + return nil + } + + // check if the Name field should be false + if len(d.Name.String) == 0 { + d.Name.Valid = false + } + + // check if the CreatedAt field should be false + if d.CreatedAt.Int64 == 0 { + d.CreatedAt.Valid = false + } + + // check if the CreatedBy field should be false + if len(d.CreatedBy.String) == 0 { + d.CreatedBy.Valid = false + } + + // check if the UpdatedAt field should be false + if d.UpdatedAt.Int64 == 0 { + d.UpdatedAt.Valid = false + } + + // check if the UpdatedBy field should be false + if len(d.UpdatedBy.String) == 0 { + d.UpdatedBy.Valid = false + } + + return d +} + +// ToAPI converts the Dashboard type +// to an API Dashboard type. +func (d *Dashboard) ToAPI() *api.Dashboard { + dashboard := new(api.Dashboard) + + dashboard.SetID(d.ID.String()) + dashboard.SetName(d.Name.String) + dashboard.SetAdmins(d.Admins) + dashboard.SetCreatedAt(d.CreatedAt.Int64) + dashboard.SetCreatedBy(d.CreatedBy.String) + dashboard.SetUpdatedAt(d.UpdatedAt.Int64) + dashboard.SetUpdatedBy(d.UpdatedBy.String) + dashboard.SetRepos(d.Repos) + + return dashboard +} + +// Validate verifies the necessary fields for +// the Dashboard type are populated correctly. +func (d *Dashboard) Validate() error { + // verify the Name field is populated + if len(d.Name.String) == 0 { + return ErrEmptyDashName + } + + // ensure that all Dashboard string fields + // that can be returned as JSON are sanitized + // to avoid unsafe HTML content + d.Name = sql.NullString{String: util.Sanitize(d.Name.String), Valid: d.Name.Valid} + + return nil +} + +// FromAPI converts the API Dashboard type +// to a database Dashboard type. +func FromAPI(d *api.Dashboard) *Dashboard { + var ( + id uuid.UUID + err error + ) + + if d.GetID() == "" { + id = uuid.New() + } else { + id, err = uuid.Parse(d.GetID()) + if err != nil { + return nil + } + } + + dashboard := &Dashboard{ + ID: id, + Name: sql.NullString{String: d.GetName(), Valid: true}, + CreatedAt: sql.NullInt64{Int64: d.GetCreatedAt(), Valid: true}, + CreatedBy: sql.NullString{String: d.GetCreatedBy(), Valid: true}, + UpdatedAt: sql.NullInt64{Int64: d.GetUpdatedAt(), Valid: true}, + UpdatedBy: sql.NullString{String: d.GetUpdatedBy(), Valid: true}, + Admins: d.GetAdmins(), + Repos: d.GetRepos(), + } + + return dashboard.Nullify() +} diff --git a/database/dashboard/dashboard_test.go b/database/dashboard/dashboard_test.go new file mode 100644 index 000000000..3073fe299 --- /dev/null +++ b/database/dashboard/dashboard_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "database/sql/driver" + "reflect" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/sirupsen/logrus" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + api "github.com/go-vela/server/api/types" +) + +func TestDashboard_New(t *testing.T) { + // setup types + logger := logrus.NewEntry(logrus.StandardLogger()) + + _sql, _mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Errorf("unable to create new SQL mock: %v", err) + } + defer _sql.Close() + + _mock.ExpectExec(CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) + + _config := &gorm.Config{SkipDefaultTransaction: true} + + _postgres, err := gorm.Open(postgres.New(postgres.Config{Conn: _sql}), _config) + if err != nil { + t.Errorf("unable to create new postgres database: %v", err) + } + + _sqlite, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), _config) + if err != nil { + t.Errorf("unable to create new sqlite database: %v", err) + } + + defer func() { _sql, _ := _sqlite.DB(); _sql.Close() }() + + // setup tests + tests := []struct { + failure bool + name string + client *gorm.DB + key string + logger *logrus.Entry + skipCreation bool + want *engine + }{ + { + failure: false, + name: "postgres", + client: _postgres, + key: "A1B2C3D4E5G6H7I8J9K0LMNOPQRSTUVW", + logger: logger, + skipCreation: false, + want: &engine{ + client: _postgres, + config: &config{SkipCreation: false}, + logger: logger, + }, + }, + { + failure: false, + name: "sqlite3", + client: _sqlite, + key: "A1B2C3D4E5G6H7I8J9K0LMNOPQRSTUVW", + logger: logger, + skipCreation: false, + want: &engine{ + client: _sqlite, + config: &config{SkipCreation: false}, + logger: logger, + }, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := New( + WithClient(test.client), + WithLogger(test.logger), + WithSkipCreation(test.skipCreation), + ) + + if test.failure { + if err == nil { + t.Errorf("New for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("New for %s returned err: %v", test.name, err) + } + + if !reflect.DeepEqual(got, test.want) { + t.Errorf("New for %s is %v, want %v", test.name, got, test.want) + } + }) + } +} + +// testPostgres is a helper function to create a Postgres engine for testing. +func testPostgres(t *testing.T) (*engine, sqlmock.Sqlmock) { + // create the new mock sql database + // + // https://pkg.go.dev/github.com/DATA-DOG/go-sqlmock#New + _sql, _mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Errorf("unable to create new SQL mock: %v", err) + } + + _mock.ExpectExec(CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) + + // create the new mock Postgres database client + // + // https://pkg.go.dev/gorm.io/gorm#Open + _postgres, err := gorm.Open( + postgres.New(postgres.Config{Conn: _sql}), + &gorm.Config{SkipDefaultTransaction: true}, + ) + if err != nil { + t.Errorf("unable to create new postgres database: %v", err) + } + + _engine, err := New( + WithClient(_postgres), + WithLogger(logrus.NewEntry(logrus.StandardLogger())), + WithSkipCreation(false), + ) + if err != nil { + t.Errorf("unable to create new postgres dashboard engine: %v", err) + } + + return _engine, _mock +} + +// testSqlite is a helper function to create a Sqlite engine for testing. +func testSqlite(t *testing.T) *engine { + _sqlite, err := gorm.Open( + sqlite.Open("file::memory:?cache=shared"), + &gorm.Config{SkipDefaultTransaction: true}, + ) + if err != nil { + t.Errorf("unable to create new sqlite database: %v", err) + } + + _engine, err := New( + WithClient(_sqlite), + WithLogger(logrus.NewEntry(logrus.StandardLogger())), + WithSkipCreation(false), + ) + if err != nil { + t.Errorf("unable to create new sqlite dashboard engine: %v", err) + } + + return _engine +} + +// testAdmin is a test helper function to create an API +// User type wil all fields set to their zero values. +func testAdmins() *[]*api.User { + return &[]*api.User{ + { + ID: new(int64), + Name: new(string), + Active: new(bool), + }, + } +} + +// testDashboard is a test helper function to create a library +// Dashboard type with all fields set to their zero values. +func testDashboard() *api.Dashboard { + return &api.Dashboard{ + ID: new(string), + Name: new(string), + CreatedAt: new(int64), + CreatedBy: new(string), + UpdatedAt: new(int64), + UpdatedBy: new(string), + Admins: testAdmins(), + } +} + +func testDashboardRepo() *api.DashboardRepo { + return &api.DashboardRepo{ + ID: new(int64), + Branches: new([]string), + Events: new([]string), + } +} + +// This will be used with the github.com/DATA-DOG/go-sqlmock library to compare values +// that are otherwise not easily compared. These typically would be values generated +// before adding or updating them in the database. +// +// https://github.com/DATA-DOG/go-sqlmock#matching-arguments-like-timetime +type NowTimestamp struct{} + +// Match satisfies sqlmock.Argument interface. +func (t NowTimestamp) Match(v driver.Value) bool { + ts, ok := v.(int64) + if !ok { + return false + } + now := time.Now().Unix() + + return now-ts < 10 +} diff --git a/database/dashboard/delete.go b/database/dashboard/delete.go new file mode 100644 index 000000000..b2b1e4090 --- /dev/null +++ b/database/dashboard/delete.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// DeleteDashboard deletes an existing dashboard from the database. +func (e *engine) DeleteDashboard(ctx context.Context, d *api.Dashboard) error { + e.logger.WithFields(logrus.Fields{ + "dashboard": d.GetID(), + }).Tracef("deleting dashboard %s from the database", d.GetID()) + + dashboard := FromAPI(d) + + // send query to the database + return e.client. + Table(constants.TableDashboard). + Delete(dashboard). + Error +} diff --git a/database/dashboard/delete_test.go b/database/dashboard/delete_test.go new file mode 100644 index 000000000..3c079efd1 --- /dev/null +++ b/database/dashboard/delete_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + api "github.com/go-vela/server/api/types" +) + +func TestDashboard_Engine_DeleteDashboard(t *testing.T) { + // setup types + _dashboard := testDashboard() + _dashboard.SetID("c8da1302-07d6-11ea-882f-4893bca275b8") + _dashboard.SetName("vela") + _dashboard.SetCreatedAt(1) + _dashboard.SetCreatedBy("user1") + _dashboard.SetUpdatedAt(1) + _dashboard.SetUpdatedBy("user2") + _dashboard.SetRepos([]*api.DashboardRepo{testDashboardRepo()}) + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // ensure the mock expects the query + _mock.ExpectExec(`DELETE FROM "dashboards" WHERE "dashboards"."id" = $1`). + WithArgs("c8da1302-07d6-11ea-882f-4893bca275b8"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + _, err := _sqlite.CreateDashboard(context.TODO(), _dashboard) + if err != nil { + t.Errorf("unable to create test dashboard for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *engine + }{ + { + failure: false, + name: "postgres", + database: _postgres, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err = test.database.DeleteDashboard(context.TODO(), _dashboard) + + if test.failure { + if err == nil { + t.Errorf("DeleteDashboard for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("DeleteDashboard for %s returned err: %v", test.name, err) + } + }) + } +} diff --git a/database/dashboard/get.go b/database/dashboard/get.go new file mode 100644 index 000000000..e4b9694b9 --- /dev/null +++ b/database/dashboard/get.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// GetDashboard gets a dashboard by UUID from the database. +func (e *engine) GetDashboard(ctx context.Context, id string) (*api.Dashboard, error) { + e.logger.Tracef("getting dashboard %s from the database", id) + + // variable to store query results + r := new(Dashboard) + + // send query to the database and store result in variable + err := e.client. + Table(constants.TableDashboard). + Where("id = ?", id). + Take(r). + Error + if err != nil { + return nil, err + } + + return r.ToAPI(), nil +} diff --git a/database/dashboard/get_test.go b/database/dashboard/get_test.go new file mode 100644 index 000000000..79664ad91 --- /dev/null +++ b/database/dashboard/get_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/go-cmp/cmp" + + api "github.com/go-vela/server/api/types" +) + +func TestRepo_Engine_GetDashboard(t *testing.T) { + // setup types + _dashRepo := new(api.DashboardRepo) + _dashRepo.SetID(1) + _dashRepo.SetBranches([]string{"main"}) + _dashRepo.SetEvents([]string{"push"}) + _dashRepos := []*api.DashboardRepo{_dashRepo} + + _admin := new(api.User) + _admin.SetID(1) + _admin.SetName("octocat") + _admin.SetActive(true) + _admins := []*api.User{_admin} + + _dashboard := testDashboard() + _dashboard.SetID("c8da1302-07d6-11ea-882f-4893bca275b8") + _dashboard.SetName("dash") + _dashboard.SetCreatedAt(1) + _dashboard.SetCreatedBy("user1") + _dashboard.SetUpdatedAt(1) + _dashboard.SetUpdatedBy("user2") + _dashboard.SetRepos(_dashRepos) + _dashboard.SetAdmins(_admins) + + // uuid, _ := uuid.Parse("c8da1302-07d6-11ea-882f-4893bca275b8") + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // create expected result in mock + _rows := sqlmock.NewRows( + []string{"id", "name", "created_at", "created_by", "updated_at", "updated_by", "admins", "repos"}, + ).AddRow("c8da1302-07d6-11ea-882f-4893bca275b8", "dash", 1, "user1", 1, "user2", []byte(`[{"id":1,"name":"octocat","active":true}]`), []byte(`[{"id":1,"branches":["main"],"events":["push"]}]`)) + + // ensure the mock expects the query + _mock.ExpectQuery(`SELECT * FROM "dashboards" WHERE id = $1 LIMIT $2`).WithArgs("c8da1302-07d6-11ea-882f-4893bca275b8", 1).WillReturnRows(_rows) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + _, err := _sqlite.CreateDashboard(context.TODO(), _dashboard) + if err != nil { + t.Errorf("unable to create test repo for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *engine + want *api.Dashboard + }{ + { + failure: false, + name: "postgres", + database: _postgres, + want: _dashboard, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + want: _dashboard, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.database.GetDashboard(context.TODO(), "c8da1302-07d6-11ea-882f-4893bca275b8") + + if test.failure { + if err == nil { + t.Errorf("GetDashboard for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("GetDashboard for %s returned err: %v", test.name, err) + } + + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("GetDashboard mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/database/dashboard/interface.go b/database/dashboard/interface.go new file mode 100644 index 000000000..027389f00 --- /dev/null +++ b/database/dashboard/interface.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + api "github.com/go-vela/server/api/types" +) + +// DashboardInterface represents the Vela interface for repo +// functions with the supported Database backends. +// +//nolint:revive // ignore name stutter +type DashboardInterface interface { + // Dashboard Data Definition Language Functions + // + // https://en.wikipedia.org/wiki/Data_definition_language + + // CreateDashboardTable defines a function that creates the dashboards table. + CreateDashboardTable(context.Context, string) error + + // Dashboard Data Manipulation Language Functions + // + // https://en.wikipedia.org/wiki/Data_manipulation_language + + // CreateDashboard defines a function that creates a dashboard. + CreateDashboard(context.Context, *api.Dashboard) (*api.Dashboard, error) + // DeleteDashboard defines a function that deletes a dashboard. + DeleteDashboard(context.Context, *api.Dashboard) error + // GetDashboard defines a function that gets a dashboard by ID. + GetDashboard(context.Context, string) (*api.Dashboard, error) + // UpdateDashboard defines a function that updates a dashboard. + UpdateDashboard(context.Context, *api.Dashboard) (*api.Dashboard, error) +} diff --git a/database/dashboard/opts.go b/database/dashboard/opts.go new file mode 100644 index 000000000..548d26d21 --- /dev/null +++ b/database/dashboard/opts.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// EngineOpt represents a configuration option to initialize the database engine for dashboards. +type EngineOpt func(*engine) error + +// WithClient sets the gorm.io/gorm client in the database engine for dashboards. +func WithClient(client *gorm.DB) EngineOpt { + return func(e *engine) error { + // set the gorm.io/gorm client in the dashboard engine + e.client = client + + return nil + } +} + +// WithDriver sets the driver type in the database engine for dashboards. +func WithDriver(driver string) EngineOpt { + return func(e *engine) error { + // set the driver type in the dashboard engine + e.config.Driver = driver + + return nil + } +} + +// WithLogger sets the github.com/sirupsen/logrus logger in the database engine for dashboards. +func WithLogger(logger *logrus.Entry) EngineOpt { + return func(e *engine) error { + // set the github.com/sirupsen/logrus logger in the dashboard engine + e.logger = logger + + return nil + } +} + +// WithSkipCreation sets the skip creation logic in the database engine for dashboards. +func WithSkipCreation(skipCreation bool) EngineOpt { + return func(e *engine) error { + // set to skip creating tables and indexes in the dashboard engine + e.config.SkipCreation = skipCreation + + return nil + } +} + +// WithContext sets the context in the database engine for dashboards. +func WithContext(ctx context.Context) EngineOpt { + return func(e *engine) error { + e.ctx = ctx + + return nil + } +} diff --git a/database/dashboard/opts_test.go b/database/dashboard/opts_test.go new file mode 100644 index 000000000..0b41b3861 --- /dev/null +++ b/database/dashboard/opts_test.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "reflect" + "testing" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func TestDashboard_EngineOpt_WithClient(t *testing.T) { + // setup types + e := &engine{client: new(gorm.DB)} + + // setup tests + tests := []struct { + failure bool + name string + client *gorm.DB + want *gorm.DB + }{ + { + failure: false, + name: "client set to new database", + client: new(gorm.DB), + want: new(gorm.DB), + }, + { + failure: false, + name: "client set to nil", + client: nil, + want: nil, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithClient(test.client)(e) + + if test.failure { + if err == nil { + t.Errorf("WithClient for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithClient returned err: %v", err) + } + + if !reflect.DeepEqual(e.client, test.want) { + t.Errorf("WithClient is %v, want %v", e.client, test.want) + } + }) + } +} + +func TestDashboard_EngineOpt_WithLogger(t *testing.T) { + // setup types + e := &engine{logger: new(logrus.Entry)} + + // setup tests + tests := []struct { + failure bool + name string + logger *logrus.Entry + want *logrus.Entry + }{ + { + failure: false, + name: "logger set to new entry", + logger: new(logrus.Entry), + want: new(logrus.Entry), + }, + { + failure: false, + name: "logger set to nil", + logger: nil, + want: nil, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithLogger(test.logger)(e) + + if test.failure { + if err == nil { + t.Errorf("WithLogger for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithLogger returned err: %v", err) + } + + if !reflect.DeepEqual(e.logger, test.want) { + t.Errorf("WithLogger is %v, want %v", e.logger, test.want) + } + }) + } +} + +func TestDashboard_EngineOpt_WithSkipCreation(t *testing.T) { + // setup types + e := &engine{config: new(config)} + + // setup tests + tests := []struct { + failure bool + name string + skipCreation bool + want bool + }{ + { + failure: false, + name: "skip creation set to true", + skipCreation: true, + want: true, + }, + { + failure: false, + name: "skip creation set to false", + skipCreation: false, + want: false, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithSkipCreation(test.skipCreation)(e) + + if test.failure { + if err == nil { + t.Errorf("WithSkipCreation for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithSkipCreation returned err: %v", err) + } + + if !reflect.DeepEqual(e.config.SkipCreation, test.want) { + t.Errorf("WithSkipCreation is %v, want %v", e.config.SkipCreation, test.want) + } + }) + } +} + +func TestDashboard_EngineOpt_WithContext(t *testing.T) { + // setup types + e := &engine{config: new(config)} + + // setup tests + tests := []struct { + failure bool + name string + ctx context.Context + want context.Context + }{ + { + failure: false, + name: "context set to TODO", + ctx: context.TODO(), + want: context.TODO(), + }, + { + failure: false, + name: "context set to nil", + ctx: nil, + want: nil, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := WithContext(test.ctx)(e) + + if test.failure { + if err == nil { + t.Errorf("WithContext for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("WithContext returned err: %v", err) + } + + if !reflect.DeepEqual(e.ctx, test.want) { + t.Errorf("WithContext is %v, want %v", e.ctx, test.want) + } + }) + } +} diff --git a/database/dashboard/table.go b/database/dashboard/table.go new file mode 100644 index 000000000..51d470a99 --- /dev/null +++ b/database/dashboard/table.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + "github.com/go-vela/types/constants" +) + +const ( + // CreatePostgresTable represents a query to create the Postgres dashboards table. + CreatePostgresTable = ` +CREATE TABLE +IF NOT EXISTS +dashboards ( + id UUID PRIMARY KEY, + name VARCHAR(250), + created_at INTEGER, + created_by VARCHAR(250), + updated_at INTEGER, + updated_by VARCHAR(250), + admins JSON DEFAULT NULL, + repos JSON DEFAULT NULL +); +` + + // CreateSqliteTable represents a query to create the Sqlite dashboards table. + CreateSqliteTable = ` +CREATE TABLE +IF NOT EXISTS +dashboards ( + id TEXT PRIMARY KEY, + name TEXT, + created_at INTEGER, + created_by TEXT, + updated_at INTEGER, + updated_by TEXT, + admins TEXT, + repos TEXT +); +` +) + +// CreateDashboardTable creates the dashboards table in the database. +func (e *engine) CreateDashboardTable(ctx context.Context, driver string) error { + e.logger.Tracef("creating dashboards table in the database") + + // handle the driver provided to create the table + switch driver { + case constants.DriverPostgres: + // create the dashboards table for Postgres + return e.client.Exec(CreatePostgresTable).Error + case constants.DriverSqlite: + fallthrough + default: + // create the dashboards table for Sqlite + return e.client.Exec(CreateSqliteTable).Error + } +} diff --git a/database/dashboard/table_test.go b/database/dashboard/table_test.go new file mode 100644 index 000000000..2f9c3d953 --- /dev/null +++ b/database/dashboard/table_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestDashboard_Engine_CreateDashboardTable(t *testing.T) { + // setup types + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + _mock.ExpectExec(CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + // setup tests + tests := []struct { + failure bool + name string + database *engine + }{ + { + failure: false, + name: "postgres", + database: _postgres, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.database.CreateDashboardTable(context.TODO(), test.name) + + if test.failure { + if err == nil { + t.Errorf("CreateDashboardTable for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("CreateDashboardTable for %s returned err: %v", test.name, err) + } + }) + } +} diff --git a/database/dashboard/update.go b/database/dashboard/update.go new file mode 100644 index 000000000..85947f545 --- /dev/null +++ b/database/dashboard/update.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// UpdateDashboard updates an existing dashboard in the database. +func (e *engine) UpdateDashboard(ctx context.Context, d *api.Dashboard) (*api.Dashboard, error) { + e.logger.WithFields(logrus.Fields{ + "dashboard": d.GetID(), + }).Tracef("creating dashboard %s in the database", d.GetID()) + + dashboard := FromAPI(d) + + err := dashboard.Validate() + if err != nil { + return nil, err + } + + // send query to the database + err = e.client.Table(constants.TableDashboard).Save(dashboard).Error + if err != nil { + return nil, err + } + + return dashboard.ToAPI(), nil +} diff --git a/database/dashboard/update_test.go b/database/dashboard/update_test.go new file mode 100644 index 000000000..adf1627e7 --- /dev/null +++ b/database/dashboard/update_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/go-cmp/cmp" + + api "github.com/go-vela/server/api/types" +) + +func TestDashboard_Engine_UpdateDashboard(t *testing.T) { + // setup types + _dashRepo := new(api.DashboardRepo) + _dashRepo.SetID(1) + _dashRepo.SetBranches([]string{"main"}) + _dashRepo.SetEvents([]string{"push"}) + _dashRepos := []*api.DashboardRepo{_dashRepo} + + _admin := new(api.User) + _admin.SetID(1) + _admin.SetName("octocat") + _admin.SetActive(true) + _admins := []*api.User{_admin} + + _dashboard := testDashboard() + _dashboard.SetID("c8da1302-07d6-11ea-882f-4893bca275b8") + _dashboard.SetName("dash") + _dashboard.SetCreatedAt(1) + _dashboard.SetCreatedBy("user1") + _dashboard.SetUpdatedAt(1) + _dashboard.SetUpdatedBy("user2") + _dashboard.SetRepos(_dashRepos) + _dashboard.SetAdmins(_admins) + + _postgres, _mock := testPostgres(t) + defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() + + // ensure the mock expects the query + _mock.ExpectExec(`UPDATE "dashboards" +SET "name"=$1,"created_at"=$2,"created_by"=$3,"updated_at"=$4,"updated_by"=$5,"admins"=$6,"repos"=$7 WHERE "id" = $8`). + WithArgs("dash", 1, "user1", NowTimestamp{}, "user2", `[{"id":1,"name":"octocat","active":true}]`, `[{"id":1,"branches":["main"],"events":["push"]}]`, "c8da1302-07d6-11ea-882f-4893bca275b8"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + _sqlite := testSqlite(t) + defer func() { _sql, _ := _sqlite.client.DB(); _sql.Close() }() + + _, err := _sqlite.CreateDashboard(context.TODO(), _dashboard) + if err != nil { + t.Errorf("unable to create test dashboard for sqlite: %v", err) + } + + // setup tests + tests := []struct { + failure bool + name string + database *engine + }{ + { + failure: false, + name: "postgres", + database: _postgres, + }, + { + failure: false, + name: "sqlite3", + database: _sqlite, + }, + } + + // run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.database.UpdateDashboard(context.TODO(), _dashboard) + _dashboard.SetUpdatedAt(got.GetUpdatedAt()) + + if test.failure { + if err == nil { + t.Errorf("UpdateDashboard for %s should have returned err", test.name) + } + + return + } + + if err != nil { + t.Errorf("UpdateDashboard for %s returned err: %v", test.name, err) + } + + if diff := cmp.Diff(got, _dashboard); diff != "" { + t.Errorf("GetDashboard mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/database/database.go b/database/database.go index 8f873f776..50d8ae956 100644 --- a/database/database.go +++ b/database/database.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "github.com/go-vela/server/database/build" + "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" "github.com/go-vela/server/database/hook" @@ -61,6 +62,7 @@ type ( logger *logrus.Entry build.BuildInterface + dashboard.DashboardInterface executable.BuildExecutableInterface deployment.DeploymentInterface hook.HookInterface diff --git a/database/integration_test.go b/database/integration_test.go index c1fb14f57..2444795e1 100644 --- a/database/integration_test.go +++ b/database/integration_test.go @@ -14,6 +14,7 @@ import ( api "github.com/go-vela/server/api/types" "github.com/go-vela/server/database/build" + "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" "github.com/go-vela/server/database/hook" @@ -34,6 +35,7 @@ import ( // Resources represents the object containing test resources. type Resources struct { Builds []*library.Build + Dashboards []*api.Dashboard Deployments []*library.Deployment Executables []*library.BuildExecutable Hooks []*library.Hook @@ -121,6 +123,8 @@ func TestDatabase_Integration(t *testing.T) { t.Run("test_builds", func(t *testing.T) { testBuilds(t, db, resources) }) + t.Run("test_dashboards", func(t *testing.T) { testDashboards(t, db, resources) }) + t.Run("test_deployments", func(t *testing.T) { testDeployments(t, db, resources) }) t.Run("test_executables", func(t *testing.T) { testExecutables(t, db, resources) }) @@ -288,6 +292,18 @@ func testBuilds(t *testing.T, db Interface, resources *Resources) { } methods["ListBuildsForRepo"] = true + list, err = db.ListBuildsForDashboardRepo(context.TODO(), resources.Repos[0], []string{"main"}, []string{"push"}) + if err != nil { + t.Errorf("unable to list build for dashboard repo %d: %v", resources.Repos[0].GetID(), err) + } + if len(list) != 1 { + t.Errorf("Number of results for ListBuildsForDashboardRepo() is %v, want %v", len(list), 1) + } + if !cmp.Equal(list, []*library.Build{resources.Builds[0]}) { + t.Errorf("ListBuildsForDashboardRepo() is %v, want %v", list, []*library.Build{resources.Builds[0]}) + } + methods["ListBuildsForDashboardRepo"] = true + // list the pending / running builds for a repo list, err = db.ListPendingAndRunningBuildsForRepo(context.TODO(), resources.Repos[0]) if err != nil { @@ -389,6 +405,90 @@ func testBuilds(t *testing.T, db Interface, resources *Resources) { } } +func testDashboards(t *testing.T, db Interface, resources *Resources) { + // create a variable to track the number of methods called for schedules + methods := make(map[string]bool) + // capture the element type of the schedule interface + element := reflect.TypeOf(new(dashboard.DashboardInterface)).Elem() + // iterate through all methods found in the schedule interface + for i := 0; i < element.NumMethod(); i++ { + // skip tracking the methods to create indexes and tables for schedules + // since those are already called when the database engine starts + if strings.Contains(element.Method(i).Name, "Index") || + strings.Contains(element.Method(i).Name, "Table") { + continue + } + + // add the method name to the list of functions + methods[element.Method(i).Name] = false + } + + ctx := context.TODO() + + // create the dashboard + for _, dashboard := range resources.Dashboards { + _, err := db.CreateDashboard(ctx, dashboard) + if err != nil { + t.Errorf("unable to create dashboard %s: %v", dashboard.GetID(), err) + } + } + methods["CreateDashboard"] = true + + // lookup the dashboards by ID + for _, dashboard := range resources.Dashboards { + got, err := db.GetDashboard(ctx, dashboard.GetID()) + if err != nil { + t.Errorf("unable to get schedule %s: %v", dashboard.GetID(), err) + } + + // JSON tags of `-` prevent unmarshaling of tokens, but they are sanitized anyway + cmpAdmins := []*api.User{} + for _, admin := range got.GetAdmins() { + admin.SetToken(constants.SecretMask) + admin.SetRefreshToken(constants.SecretMask) + + cmpAdmins = append(cmpAdmins, admin) + } + + got.SetAdmins(cmpAdmins) + + if !cmp.Equal(got, dashboard, CmpOptApproxUpdatedAt()) { + t.Errorf("GetDashboard() is %v, want %v", got, dashboard) + } + } + methods["GetDashboard"] = true + + // update the dashboards + for _, dashboard := range resources.Dashboards { + dashboard.SetUpdatedAt(time.Now().UTC().Unix()) + got, err := db.UpdateDashboard(ctx, dashboard) + if err != nil { + t.Errorf("unable to update dashboard %s: %v", dashboard.GetID(), err) + } + + if !cmp.Equal(got, dashboard, CmpOptApproxUpdatedAt()) { + t.Errorf("UpdateDashboard() is %v, want %v", got, dashboard) + } + } + methods["UpdateDashboard"] = true + + // delete the schedules + for _, dashboard := range resources.Dashboards { + err := db.DeleteDashboard(ctx, dashboard) + if err != nil { + t.Errorf("unable to delete schedule %s: %v", dashboard.GetID(), err) + } + } + methods["DeleteDashboard"] = true + + // ensure we called all the methods we expected to + for method, called := range methods { + if !called { + t.Errorf("method %s was not called for dashboards", method) + } + } +} + func testExecutables(t *testing.T, db Interface, resources *Resources) { // create a variable to track the number of methods called for pipelines methods := make(map[string]bool) @@ -1817,6 +1917,7 @@ func testUsers(t *testing.T, db Interface, resources *Resources) { userOne.SetToken("") userOne.SetRefreshToken("") userOne.SetFavorites(nil) + userOne.SetDashboards(nil) userOne.SetActive(false) userOne.SetAdmin(false) @@ -1826,6 +1927,7 @@ func testUsers(t *testing.T, db Interface, resources *Resources) { userTwo.SetToken("") userTwo.SetRefreshToken("") userTwo.SetFavorites(nil) + userTwo.SetDashboards(nil) userTwo.SetActive(false) userTwo.SetAdmin(false) @@ -2009,6 +2111,26 @@ func testWorkers(t *testing.T, db Interface, resources *Resources) { } func newResources() *Resources { + userOne := new(api.User) + userOne.SetID(1) + userOne.SetName("octocat") + userOne.SetToken("superSecretToken") + userOne.SetRefreshToken("superSecretRefreshToken") + userOne.SetFavorites([]string{"github/octocat"}) + userOne.SetActive(true) + userOne.SetAdmin(false) + userOne.SetDashboards([]string{"45bcf19b-c151-4e2d-b8c6-80a62ba2eae7"}) + + userTwo := new(api.User) + userTwo.SetID(2) + userTwo.SetName("octokitty") + userTwo.SetToken("superSecretToken") + userTwo.SetRefreshToken("superSecretRefreshToken") + userTwo.SetFavorites([]string{"github/octocat"}) + userTwo.SetActive(true) + userTwo.SetAdmin(false) + userTwo.SetDashboards([]string{"45bcf19b-c151-4e2d-b8c6-80a62ba2eae7", "ba657dab-bc6e-421f-9188-86272bd0069a"}) + buildOne := new(library.Build) buildOne.SetID(1) buildOne.SetRepoID(1) @@ -2081,6 +2203,32 @@ func newResources() *Resources { buildTwo.SetApprovedAt(1563474078) buildTwo.SetApprovedBy("OctoCat") + dashRepo := new(api.DashboardRepo) + dashRepo.SetID(1) + dashRepo.SetName("go-vela/server") + dashRepo.SetBranches([]string{"main"}) + dashRepo.SetEvents([]string{"push"}) + + dashboardOne := new(api.Dashboard) + dashboardOne.SetID("ba657dab-bc6e-421f-9188-86272bd0069a") + dashboardOne.SetName("vela") + dashboardOne.SetCreatedAt(1) + dashboardOne.SetCreatedBy("octocat") + dashboardOne.SetUpdatedAt(2) + dashboardOne.SetUpdatedBy("octokitty") + dashboardOne.SetAdmins([]*api.User{userOne.Sanitize(), userTwo.Sanitize()}) + dashboardOne.SetRepos([]*api.DashboardRepo{dashRepo}) + + dashboardTwo := new(api.Dashboard) + dashboardTwo.SetID("45bcf19b-c151-4e2d-b8c6-80a62ba2eae7") + dashboardTwo.SetName("vela") + dashboardTwo.SetCreatedAt(1) + dashboardTwo.SetCreatedBy("octocat") + dashboardTwo.SetUpdatedAt(2) + dashboardTwo.SetUpdatedBy("octokitty") + dashboardTwo.SetAdmins([]*api.User{userOne.Sanitize(), userTwo.Sanitize()}) + dashboardTwo.SetRepos([]*api.DashboardRepo{dashRepo}) + executableOne := new(library.BuildExecutable) executableOne.SetID(1) executableOne.SetBuildID(1) @@ -2236,24 +2384,6 @@ func newResources() *Resources { pipelineTwo.SetTemplates(false) pipelineTwo.SetData([]byte("version: 1")) - userOne := new(api.User) - userOne.SetID(1) - userOne.SetName("octocat") - userOne.SetToken("superSecretToken") - userOne.SetRefreshToken("superSecretRefreshToken") - userOne.SetFavorites([]string{"github/octocat"}) - userOne.SetActive(true) - userOne.SetAdmin(false) - - userTwo := new(api.User) - userTwo.SetID(2) - userTwo.SetName("octokitty") - userTwo.SetToken("superSecretToken") - userTwo.SetRefreshToken("superSecretRefreshToken") - userTwo.SetFavorites([]string{"github/octocat"}) - userTwo.SetActive(true) - userTwo.SetAdmin(false) - repoOne := new(api.Repo) repoOne.SetID(1) repoOne.SetOwner(userOne.Sanitize()) @@ -2485,6 +2615,7 @@ func newResources() *Resources { return &Resources{ Builds: []*library.Build{buildOne, buildTwo}, + Dashboards: []*api.Dashboard{dashboardOne, dashboardTwo}, Deployments: []*library.Deployment{deploymentOne, deploymentTwo}, Executables: []*library.BuildExecutable{executableOne, executableTwo}, Hooks: []*library.Hook{hookOne, hookTwo, hookThree}, diff --git a/database/interface.go b/database/interface.go index 4a51019b3..16a0fdf27 100644 --- a/database/interface.go +++ b/database/interface.go @@ -4,6 +4,7 @@ package database import ( "github.com/go-vela/server/database/build" + "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" "github.com/go-vela/server/database/hook" @@ -39,6 +40,9 @@ type Interface interface { // BuildExecutableInterface defines the interface for build executables stored in the database. executable.BuildExecutableInterface + // DashboardInterface defines the interface for builds store in the database. + dashboard.DashboardInterface + // DeploymentInterface defines the interface for deployments stored in the database. deployment.DeploymentInterface diff --git a/database/repo/get_org_test.go b/database/repo/get_org_test.go index 3cf2fd9a3..dc168088c 100644 --- a/database/repo/get_org_test.go +++ b/database/repo/get_org_test.go @@ -12,7 +12,6 @@ import ( api "github.com/go-vela/server/api/types" "github.com/go-vela/server/database/user" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" ) func TestRepo_Engine_GetRepoForOrg(t *testing.T) { @@ -58,7 +57,7 @@ func TestRepo_Engine_GetRepoForOrg(t *testing.T) { t.Errorf("unable to create test repo for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.User{}) + err = _sqlite.client.AutoMigrate(&user.User{}) if err != nil { t.Errorf("unable to create build table for sqlite: %v", err) } diff --git a/database/repo/get_test.go b/database/repo/get_test.go index 7d7618fd3..9b3ded139 100644 --- a/database/repo/get_test.go +++ b/database/repo/get_test.go @@ -12,7 +12,6 @@ import ( api "github.com/go-vela/server/api/types" "github.com/go-vela/server/database/user" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" ) func TestRepo_Engine_GetRepo(t *testing.T) { @@ -58,7 +57,7 @@ func TestRepo_Engine_GetRepo(t *testing.T) { t.Errorf("unable to create test repo for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.User{}) + err = _sqlite.client.AutoMigrate(&user.User{}) if err != nil { t.Errorf("unable to create build table for sqlite: %v", err) } diff --git a/database/repo/list_org_test.go b/database/repo/list_org_test.go index 47fa34e1b..54af05969 100644 --- a/database/repo/list_org_test.go +++ b/database/repo/list_org_test.go @@ -131,7 +131,7 @@ func TestRepo_Engine_ListReposForOrg(t *testing.T) { t.Errorf("unable to create test build for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.User{}) + err = _sqlite.client.AutoMigrate(&user.User{}) if err != nil { t.Errorf("unable to create build table for sqlite: %v", err) } diff --git a/database/repo/list_test.go b/database/repo/list_test.go index 84738be32..5adbc0fa6 100644 --- a/database/repo/list_test.go +++ b/database/repo/list_test.go @@ -12,7 +12,6 @@ import ( api "github.com/go-vela/server/api/types" "github.com/go-vela/server/database/user" "github.com/go-vela/types/constants" - "github.com/go-vela/types/database" ) func TestRepo_Engine_ListRepos(t *testing.T) { @@ -82,7 +81,7 @@ func TestRepo_Engine_ListRepos(t *testing.T) { t.Errorf("unable to create test repo for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.User{}) + err = _sqlite.client.AutoMigrate(&user.User{}) if err != nil { t.Errorf("unable to create build table for sqlite: %v", err) } diff --git a/database/repo/list_user_test.go b/database/repo/list_user_test.go index d4e3152f5..1bdae5d36 100644 --- a/database/repo/list_user_test.go +++ b/database/repo/list_user_test.go @@ -133,7 +133,7 @@ func TestRepo_Engine_ListReposForUser(t *testing.T) { t.Errorf("unable to create test build for sqlite: %v", err) } - err = _sqlite.client.AutoMigrate(&database.User{}) + err = _sqlite.client.AutoMigrate(&user.User{}) if err != nil { t.Errorf("unable to create build table for sqlite: %v", err) } diff --git a/database/resource.go b/database/resource.go index 2e309dedf..d592179c8 100644 --- a/database/resource.go +++ b/database/resource.go @@ -6,6 +6,7 @@ import ( "context" "github.com/go-vela/server/database/build" + "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" "github.com/go-vela/server/database/hook" @@ -35,6 +36,16 @@ func (e *engine) NewResources(ctx context.Context) error { return err } + e.DashboardInterface, err = dashboard.New( + dashboard.WithContext(e.ctx), + dashboard.WithClient(e.client), + dashboard.WithLogger(e.logger), + dashboard.WithSkipCreation(e.config.SkipCreation), + ) + if err != nil { + return err + } + // create the database agnostic engine for build_executables e.BuildExecutableInterface, err = executable.New( executable.WithContext(e.ctx), diff --git a/database/resource_test.go b/database/resource_test.go index 5819739c4..dc99bb63b 100644 --- a/database/resource_test.go +++ b/database/resource_test.go @@ -9,6 +9,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/go-vela/server/database/build" + "github.com/go-vela/server/database/dashboard" "github.com/go-vela/server/database/deployment" "github.com/go-vela/server/database/executable" "github.com/go-vela/server/database/hook" @@ -33,6 +34,8 @@ func TestDatabase_Engine_NewResources(t *testing.T) { _mock.ExpectExec(build.CreateRepoIDIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(build.CreateSourceIndex).WillReturnResult(sqlmock.NewResult(1, 1)) _mock.ExpectExec(build.CreateStatusIndex).WillReturnResult(sqlmock.NewResult(1, 1)) + // ensure the mock expects the dashboard queries + _mock.ExpectExec(dashboard.CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) // ensure the mock expects the build executable queries _mock.ExpectExec(executable.CreatePostgresTable).WillReturnResult(sqlmock.NewResult(1, 1)) // ensure the mock expects the deployment queries diff --git a/database/user/create_test.go b/database/user/create_test.go index c9a8acdf0..988cac912 100644 --- a/database/user/create_test.go +++ b/database/user/create_test.go @@ -25,9 +25,9 @@ func TestUser_Engine_CreateUser(t *testing.T) { // ensure the mock expects the query _mock.ExpectQuery(`INSERT INTO "users" -("name","refresh_token","token","favorites","active","admin","id") -VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING "id"`). - WithArgs("foo", AnyArgument{}, AnyArgument{}, nil, false, false, 1). +("name","refresh_token","token","favorites","active","admin","dashboards","id") +VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id"`). + WithArgs("foo", AnyArgument{}, AnyArgument{}, nil, false, false, AnyArgument{}, 1). WillReturnRows(_rows) _sqlite := testSqlite(t) diff --git a/database/user/get_name_test.go b/database/user/get_name_test.go index 86b812ca0..72a9c1e08 100644 --- a/database/user/get_name_test.go +++ b/database/user/get_name_test.go @@ -20,14 +20,15 @@ func TestUser_Engine_GetUserForName(t *testing.T) { _user.SetToken("bar") _user.SetFavorites([]string{}) + _user.SetDashboards([]string{}) _postgres, _mock := testPostgres(t) defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() // create expected result in mock _rows := sqlmock.NewRows( - []string{"id", "name", "refresh_token", "token", "hash", "favorites", "active", "admin"}). - AddRow(1, "foo", "", "bar", "baz", "{}", false, false) + []string{"id", "name", "refresh_token", "token", "hash", "favorites", "active", "admin", "dashboards"}). + AddRow(1, "foo", "", "bar", "baz", "{}", false, false, "{}") // ensure the mock expects the query _mock.ExpectQuery(`SELECT * FROM "users" WHERE name = $1 LIMIT $2`).WithArgs("foo", 1).WillReturnRows(_rows) diff --git a/database/user/get_test.go b/database/user/get_test.go index b92f2bee4..213da30f8 100644 --- a/database/user/get_test.go +++ b/database/user/get_test.go @@ -20,14 +20,15 @@ func TestUser_Engine_GetUser(t *testing.T) { _user.SetToken("bar") _user.SetFavorites([]string{}) + _user.SetDashboards([]string{}) _postgres, _mock := testPostgres(t) defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() // create expected result in mock _rows := sqlmock.NewRows( - []string{"id", "name", "refresh_token", "token", "hash", "favorites", "active", "admin"}). - AddRow(1, "foo", "", "bar", "baz", "{}", false, false) + []string{"id", "name", "refresh_token", "token", "hash", "favorites", "active", "admin", "dashboards"}). + AddRow(1, "foo", "", "bar", "baz", "{}", false, false, "{}") // ensure the mock expects the query _mock.ExpectQuery(`SELECT * FROM "users" WHERE id = $1 LIMIT $2`).WithArgs(1, 1).WillReturnRows(_rows) diff --git a/database/user/list_lite_test.go b/database/user/list_lite_test.go index 780b1e29a..6f2888e50 100644 --- a/database/user/list_lite_test.go +++ b/database/user/list_lite_test.go @@ -19,12 +19,14 @@ func TestUser_Engine_ListLiteUsers(t *testing.T) { _userOne.SetName("foo") _userOne.SetToken("bar") _userOne.SetFavorites([]string{}) + _userOne.SetDashboards([]string{}) _userTwo := testAPIUser() _userTwo.SetID(2) _userTwo.SetName("baz") _userTwo.SetToken("bar") _userTwo.SetFavorites([]string{}) + _userTwo.SetDashboards([]string{}) _postgres, _mock := testPostgres(t) defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() @@ -61,10 +63,12 @@ func TestUser_Engine_ListLiteUsers(t *testing.T) { _userOne.RefreshToken = new(string) _userOne.Token = new(string) _userOne.Favorites = new([]string) + _userOne.Dashboards = new([]string) _userTwo.RefreshToken = new(string) _userTwo.Token = new(string) _userTwo.Favorites = new([]string) + _userTwo.Dashboards = new([]string) // setup tests tests := []struct { diff --git a/database/user/list_test.go b/database/user/list_test.go index 556e02e57..01a847ea8 100644 --- a/database/user/list_test.go +++ b/database/user/list_test.go @@ -19,12 +19,14 @@ func TestUser_Engine_ListUsers(t *testing.T) { _userOne.SetName("foo") _userOne.SetToken("bar") _userOne.SetFavorites([]string{}) + _userOne.SetDashboards([]string{}) _userTwo := testAPIUser() _userTwo.SetID(2) _userTwo.SetName("baz") _userTwo.SetToken("bar") _userTwo.SetFavorites([]string{}) + _userTwo.SetDashboards([]string{}) _postgres, _mock := testPostgres(t) defer func() { _sql, _ := _postgres.client.DB(); _sql.Close() }() @@ -37,9 +39,9 @@ func TestUser_Engine_ListUsers(t *testing.T) { // create expected result in mock _rows = sqlmock.NewRows( - []string{"id", "name", "refresh_token", "token", "hash", "favorites", "active", "admin"}). - AddRow(1, "foo", "", "bar", "baz", "{}", false, false). - AddRow(2, "baz", "", "bar", "foo", "{}", false, false) + []string{"id", "name", "refresh_token", "token", "hash", "favorites", "active", "admin", "dashboards"}). + AddRow(1, "foo", "", "bar", "baz", "{}", false, false, "{}"). + AddRow(2, "baz", "", "bar", "foo", "{}", false, false, "{}") // ensure the mock expects the query _mock.ExpectQuery(`SELECT * FROM "users"`).WillReturnRows(_rows) diff --git a/database/user/table.go b/database/user/table.go index 3ba809872..c5e4d1712 100644 --- a/database/user/table.go +++ b/database/user/table.go @@ -21,6 +21,7 @@ users ( favorites VARCHAR(5000), active BOOLEAN, admin BOOLEAN, + dashboards VARCHAR(5000), UNIQUE(name) ); ` @@ -37,6 +38,7 @@ users ( favorites TEXT, active BOOLEAN, admin BOOLEAN, + dashboards TEXT, UNIQUE(name) ); ` diff --git a/database/user/update_test.go b/database/user/update_test.go index d84022b87..5fbb7ed8b 100644 --- a/database/user/update_test.go +++ b/database/user/update_test.go @@ -22,9 +22,9 @@ func TestUser_Engine_UpdateUser(t *testing.T) { // ensure the mock expects the query _mock.ExpectExec(`UPDATE "users" -SET "name"=$1,"refresh_token"=$2,"token"=$3,"favorites"=$4,"active"=$5,"admin"=$6 -WHERE "id" = $7`). - WithArgs("foo", AnyArgument{}, AnyArgument{}, nil, false, false, 1). +SET "name"=$1,"refresh_token"=$2,"token"=$3,"favorites"=$4,"active"=$5,"admin"=$6,"dashboards"=$7 +WHERE "id" = $8`). + WithArgs("foo", AnyArgument{}, AnyArgument{}, nil, false, false, nil, 1). WillReturnResult(sqlmock.NewResult(1, 1)) _sqlite := testSqlite(t) diff --git a/database/user/user.go b/database/user/user.go index d961f71b0..a29295cbb 100644 --- a/database/user/user.go +++ b/database/user/user.go @@ -43,6 +43,10 @@ var ( // ErrExceededFavoritesLimit defines the error type when a // User type has Favorites field provided that exceeds the database limit. ErrExceededFavoritesLimit = errors.New("exceeded favorites limit") + + // ErrExceededDashboardsLimit defines the error type when a + // User type has Dashboards field provided that exceeds the database limit. + ErrExceededDashboardsLimit = errors.New("exceeded dashboards limit") ) type ( @@ -81,6 +85,7 @@ type ( Favorites pq.StringArray `sql:"favorites" gorm:"type:varchar(5000)"` Active sql.NullBool `sql:"active"` Admin sql.NullBool `sql:"admin"` + Dashboards pq.StringArray `sql:"dashboards" gorm:"type:varchar(5000)"` } ) @@ -249,6 +254,7 @@ func (u *User) ToAPI() *api.User { user.SetActive(u.Active.Bool) user.SetAdmin(u.Admin.Bool) user.SetFavorites(u.Favorites) + user.SetDashboards(u.Dashboards) return user } @@ -271,19 +277,32 @@ func (u *User) Validate() error { return ErrInvalidUserName } - // calculate total size of favorites - total := 0 + // calculate totalFavorites size of favorites + totalFavorites := 0 for _, f := range u.Favorites { - total += len(f) + totalFavorites += len(f) } // verify the Favorites field is within the database constraints // len is to factor in number of comma separators included in the database field, // removing 1 due to the last item not having an appended comma - if (total + len(u.Favorites) - 1) > constants.FavoritesMaxSize { + if (totalFavorites + len(u.Favorites) - 1) > constants.FavoritesMaxSize { return ErrExceededFavoritesLimit } + // calculate totalDashboards size of dashboards + totalDashboards := 0 + for _, d := range u.Dashboards { + totalDashboards += len(d) + } + + // verify the Dashboards field is within the database constraints + // len is to factor in number of comma separators included in the database field, + // removing 1 due to the last item not having an appended comma + if (totalDashboards + len(u.Dashboards) - 1) > constants.FavoritesMaxSize { + return ErrExceededDashboardsLimit + } + // ensure that all User string fields // that can be returned as JSON are sanitized // to avoid unsafe HTML content @@ -309,6 +328,7 @@ func FromAPI(u *api.User) *User { Active: sql.NullBool{Bool: u.GetActive(), Valid: true}, Admin: sql.NullBool{Bool: u.GetAdmin(), Valid: true}, Favorites: pq.StringArray(u.GetFavorites()), + Dashboards: pq.StringArray(u.GetDashboards()), } return user.Nullify() diff --git a/database/user/user_test.go b/database/user/user_test.go index 08db61eef..287ee9508 100644 --- a/database/user/user_test.go +++ b/database/user/user_test.go @@ -183,6 +183,7 @@ func testAPIUser() *api.User { Favorites: new([]string), Active: new(bool), Admin: new(bool), + Dashboards: new([]string), } } @@ -342,6 +343,7 @@ func TestUser_ToAPI(t *testing.T) { want.SetFavorites([]string{"github/octocat"}) want.SetActive(true) want.SetAdmin(false) + want.SetDashboards([]string{"45bcf19b-c151-4e2d-b8c6-80a62ba2eae7"}) // run test got := testUser().ToAPI() @@ -390,7 +392,16 @@ func TestUser_Validate(t *testing.T) { ID: sql.NullInt64{Int64: 1, Valid: true}, Name: sql.NullString{String: "octocat", Valid: true}, Token: sql.NullString{String: "superSecretToken", Valid: true}, - Favorites: exceededFavorites(), + Favorites: exceededField(), + }, + }, + { // invalid dashboards set for user + failure: true, + user: &User{ + ID: sql.NullInt64{Int64: 1, Valid: true}, + Name: sql.NullString{String: "octocat", Valid: true}, + Token: sql.NullString{String: "superSecretToken", Valid: true}, + Dashboards: exceededField(), }, }, } @@ -424,6 +435,7 @@ func TestFromAPI(t *testing.T) { u.SetFavorites([]string{"github/octocat"}) u.SetActive(true) u.SetAdmin(false) + u.SetDashboards([]string{"45bcf19b-c151-4e2d-b8c6-80a62ba2eae7"}) want := testUser() @@ -446,22 +458,23 @@ func testUser() *User { Favorites: []string{"github/octocat"}, Active: sql.NullBool{Bool: true, Valid: true}, Admin: sql.NullBool{Bool: false, Valid: true}, + Dashboards: []string{"45bcf19b-c151-4e2d-b8c6-80a62ba2eae7"}, } } -// exceededFavorites returns a list of valid favorites that exceed the maximum size. -func exceededFavorites() []string { +// exceededField returns a list of strings that exceed the maximum size of a field. +func exceededField() []string { // initialize empty favorites - favorites := []string{} + values := []string{} - // add enough favorites to exceed the character limit + // add enough strings to exceed the character limit for i := 0; i < 500; i++ { - // construct favorite + // construct field // use i to adhere to unique favorites - favorite := "github/octocat-" + strconv.Itoa(i) + field := "github/octocat-" + strconv.Itoa(i) - favorites = append(favorites, favorite) + values = append(values, field) } - return favorites + return values } diff --git a/mock/server/user.go b/mock/server/user.go index e2fb9511c..54fbedd64 100644 --- a/mock/server/user.go +++ b/mock/server/user.go @@ -23,7 +23,8 @@ const ( "token": null, "favorites": ["github/octocat"], "active": true, - "admin": false + "admin": false, + "dashboards": [] }` // UsersResp represents a JSON return for one to many users. @@ -34,7 +35,8 @@ const ( "token": null, "favorites": ["github/octocat"], "active": true, - "admin": false + "admin": false, + "dashboards": [] }, { "id": 1, @@ -42,7 +44,8 @@ const ( "token": null, "favorites": ["github/octocat"], "active": true, - "admin": false + "admin": false, + "dashboards": [] } ]` ) diff --git a/router/dashboard.go b/router/dashboard.go new file mode 100644 index 000000000..57ffafddb --- /dev/null +++ b/router/dashboard.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package router + +import ( + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/api/dashboard" + dMiddleware "github.com/go-vela/server/router/middleware/dashboard" +) + +// DashboardHandlers is a function that extends the provided base router group +// with the API handlers for resource search functionality. +// +// GET /api/v1/search/builds/:id . +func DashboardHandlers(base *gin.RouterGroup) { + // Search endpoints + dashboards := base.Group("/dashboards") + { + dashboards.POST("", dashboard.CreateDashboard) + + d := dashboards.Group("/:dashboard", dMiddleware.Establish()) + { + d.GET("", dashboard.GetDashboard) + d.PUT("", dashboard.UpdateDashboard) + d.DELETE("", dashboard.DeleteDashboard) + } + } // end of search endpoints +} diff --git a/router/middleware/dashboard/context.go b/router/middleware/dashboard/context.go new file mode 100644 index 000000000..f0f973015 --- /dev/null +++ b/router/middleware/dashboard/context.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + + api "github.com/go-vela/server/api/types" +) + +const key = "dashboard" + +// Setter defines a context that enables setting values. +type Setter interface { + Set(string, interface{}) +} + +// FromContext returns the Dashboard associated with this context. +func FromContext(c context.Context) *api.Dashboard { + value := c.Value(key) + if value == nil { + return nil + } + + b, ok := value.(*api.Dashboard) + if !ok { + return nil + } + + return b +} + +// ToContext adds the Dashboard to this context if it supports +// the Setter interface. +func ToContext(c Setter, b *api.Dashboard) { + c.Set(key, b) +} diff --git a/router/middleware/dashboard/context_test.go b/router/middleware/dashboard/context_test.go new file mode 100644 index 000000000..abda97051 --- /dev/null +++ b/router/middleware/dashboard/context_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "testing" + + "github.com/gin-gonic/gin" + + api "github.com/go-vela/server/api/types" +) + +func TestDashboard_FromContext(t *testing.T) { + // setup types + uuid := "c8da1302-07d6-11ea-882f-4893bca275b8" + want := &api.Dashboard{ID: &uuid} + + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + context.Set(key, want) + + // run test + got := FromContext(context) + + if got != want { + t.Errorf("FromContext is %v, want %v", got, want) + } +} + +func TestDashboard_FromContext_Bad(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + context.Set(key, nil) + + // run test + got := FromContext(context) + + if got != nil { + t.Errorf("FromContext is %v, want nil", got) + } +} + +func TestDashboard_FromContext_WrongType(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + context.Set(key, 1) + + // run test + got := FromContext(context) + + if got != nil { + t.Errorf("FromContext is %v, want nil", got) + } +} + +func TestDashboard_FromContext_Empty(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + + // run test + got := FromContext(context) + + if got != nil { + t.Errorf("FromContext is %v, want nil", got) + } +} + +func TestDashboard_ToContext(t *testing.T) { + // setup types + uuid := "c8da1302-07d6-11ea-882f-4893bca275b8" + want := &api.Dashboard{ID: &uuid} + + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + ToContext(context, want) + + // run test + got := context.Value(key) + + if got != want { + t.Errorf("ToContext is %v, want %v", got, want) + } +} diff --git a/router/middleware/dashboard/dashboard.go b/router/middleware/dashboard/dashboard.go new file mode 100644 index 000000000..b62db4b90 --- /dev/null +++ b/router/middleware/dashboard/dashboard.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/util" +) + +// Retrieve gets the build in the given context. +func Retrieve(c *gin.Context) *api.Dashboard { + return FromContext(c) +} + +// Establish sets the build in the given context. +func Establish() gin.HandlerFunc { + return func(c *gin.Context) { + u := user.Retrieve(c) + ctx := c.Request.Context() + + id := util.PathParameter(c, "dashboard") + if len(id) == 0 { + userBoards := u.GetDashboards() + if len(userBoards) == 0 { + retErr := fmt.Errorf("no available dashboards for user %s", u.GetName()) + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + id = userBoards[0] + } + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "dashboard": id, + "user": u.GetName(), + }).Debugf("reading dashboard %s", id) + + d, err := database.FromContext(c).GetDashboard(ctx, id) + if err != nil { + retErr := fmt.Errorf("unable to read dashboard %s: %w", id, err) + util.HandleError(c, http.StatusNotFound, retErr) + + return + } + + ToContext(c, d) + c.Next() + } +} diff --git a/router/middleware/dashboard/dashboard_test.go b/router/middleware/dashboard/dashboard_test.go new file mode 100644 index 000000000..35509c19e --- /dev/null +++ b/router/middleware/dashboard/dashboard_test.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package dashboard + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/gin-gonic/gin" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/database" +) + +func TestDashboard_Retrieve(t *testing.T) { + // setup types + want := new(api.Dashboard) + want.SetID("c8da1302-07d6-11ea-882f-4893bca275b8") + + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + ToContext(context, want) + + // run test + got := Retrieve(context) + + if got != want { + t.Errorf("Retrieve is %v, want %v", got, want) + } +} + +func TestDashboard_Establish(t *testing.T) { + // setup types + want := new(api.Dashboard) + want.SetID("c8da1302-07d6-11ea-882f-4893bca275b8") + want.SetName("vela") + want.SetCreatedAt(1) + want.SetCreatedBy("octocat") + want.SetUpdatedAt(1) + want.SetUpdatedBy("octokitty") + + wantRepo := new(api.DashboardRepo) + wantRepo.SetID(1) + wantRepo.SetName("go-vela/server") + wantRepo.SetBranches([]string{"main"}) + wantRepo.SetEvents([]string{"push"}) + + want.SetRepos([]*api.DashboardRepo{wantRepo}) + + wantAdmin := new(api.User) + wantAdmin.SetID(1) + wantAdmin.SetName("octocat") + wantAdmin.SetActive(true) + + want.SetAdmins([]*api.User{wantAdmin}) + + got := new(api.Dashboard) + + // setup database + db, err := database.NewTest() + if err != nil { + t.Errorf("unable to create test database engine: %v", err) + } + + defer func() { + _ = db.DeleteDashboard(context.TODO(), want) + db.Close() + }() + + _, _ = db.CreateDashboard(context.TODO(), want) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "/c8da1302-07d6-11ea-882f-4893bca275b8", nil) + + // setup mock server + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(Establish()) + engine.GET("/:dashboard", func(c *gin.Context) { + got = Retrieve(c) + + c.Status(http.StatusOK) + }) + + // run test + engine.ServeHTTP(resp, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusOK) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Establish is %v, want %v", got, want) + } +} + +func TestDashboard_Establish_NoDashboardParameter(t *testing.T) { + // setup database + db, err := database.NewTest() + if err != nil { + t.Errorf("unable to create test database engine: %v", err) + } + defer db.Close() + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "//test", nil) + + // setup mock server + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(Establish()) + engine.GET("/:dashboard/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusBadRequest { + t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusBadRequest) + } +} + +func TestDashboard_Establish_NoDashboard(t *testing.T) { + // setup database + db, err := database.NewTest() + if err != nil { + t.Errorf("unable to create test database engine: %v", err) + } + defer db.Close() + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "/c8da1302-07d6-11ea-882f-4893bca275b8", nil) + + // setup mock server + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(Establish()) + engine.GET("/:dashboard", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusNotFound { + t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusNotFound) + } +} diff --git a/router/middleware/user/user_test.go b/router/middleware/user/user_test.go index 04e4499b2..a6809c334 100644 --- a/router/middleware/user/user_test.go +++ b/router/middleware/user/user_test.go @@ -61,6 +61,7 @@ func TestUser_Establish(t *testing.T) { want.SetActive(false) want.SetAdmin(false) want.SetFavorites([]string{}) + want.SetDashboards([]string{}) got := new(api.User) diff --git a/router/router.go b/router/router.go index 8f0559ba7..8095da38f 100644 --- a/router/router.go +++ b/router/router.go @@ -125,7 +125,7 @@ func Load(options ...gin.HandlerFunc) *gin.Engine { ScmHandlers(baseAPI) // Search endpoints - SearchHandlers(baseAPI) + DashboardHandlers(baseAPI) // Secret endpoints SecretHandlers(baseAPI) diff --git a/router/user.go b/router/user.go index 64f41b6a5..149fe3146 100644 --- a/router/user.go +++ b/router/user.go @@ -5,6 +5,7 @@ package router import ( "github.com/gin-gonic/gin" + "github.com/go-vela/server/api/dashboard" "github.com/go-vela/server/api/user" "github.com/go-vela/server/router/middleware/perm" ) @@ -21,7 +22,8 @@ import ( // PUT /api/v1/user // GET /api/v1/user/source/repos // POST /api/v1/user/token -// DELETE /api/v1/user/token . +// DELETE /api/v1/user/token +// GET /api/v1/user/dashboards . func UserHandlers(base *gin.RouterGroup) { // Users endpoints _users := base.Group("/users") @@ -41,5 +43,6 @@ func UserHandlers(base *gin.RouterGroup) { _user.GET("/source/repos", user.GetSourceRepos) _user.POST("/token", user.CreateToken) _user.DELETE("/token", user.DeleteToken) + _user.GET("/dashboards", dashboard.ListUserDashboards) } // end of user endpoints }