diff --git a/pkg/api/api.go b/pkg/api/api.go index 1320663f630..84fd6f8f659 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -246,6 +246,24 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) + // Folders + apiRoute.Group("/folders", func(folderRoute RouteRegister) { + folderRoute.Get("/", wrap(GetFolders)) + folderRoute.Get("/id/:id", wrap(GetFolderById)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) { + folderUidRoute.Get("/", wrap(GetFolderByUid)) + folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) + folderUidRoute.Delete("/", wrap(DeleteFolder)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) { + folderPermissionRoute.Get("/", wrap(GetFolderPermissionList)) + folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions)) + }) + }) + }) + // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) @@ -266,9 +284,9 @@ func (hs *HttpServer) registerRoutes() { dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) - dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { - aclRoute.Get("/", wrap(GetDashboardAclList)) - aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) + dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) { + dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList)) + dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions)) }) }) }) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index a079f6f7681..88502804919 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -137,6 +137,7 @@ func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dash if err := bus.Dispatch(&query); err != nil { return nil, ApiError(404, "Dashboard not found", err) } + return query.Result, nil } @@ -166,8 +167,10 @@ func DeleteDashboard(c *middleware.Context) Response { return ApiError(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": dash.Title} - return Json(200, resp) + return Json(200, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), + }) } func DeleteDashboardByUid(c *middleware.Context) Response { @@ -186,8 +189,10 @@ func DeleteDashboardByUid(c *middleware.Context) Response { return ApiError(500, "Failed to delete dashboard", err) } - var resp = map[string]interface{}{"title": dash.Title} - return Json(200, resp) + return Json(200, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), + }) } func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response { diff --git a/pkg/api/dashboard_acl.go b/pkg/api/dashboard_permission.go similarity index 87% rename from pkg/api/dashboard_acl.go rename to pkg/api/dashboard_permission.go index 13b29db78e6..351b81885d6 100644 --- a/pkg/api/dashboard_acl.go +++ b/pkg/api/dashboard_permission.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" ) -func GetDashboardAclList(c *middleware.Context) Response { +func GetDashboardPermissionList(c *middleware.Context) Response { dashId := c.ParamsInt64(":dashboardId") _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") @@ -26,7 +26,7 @@ func GetDashboardAclList(c *middleware.Context) Response { acl, err := guardian.GetAcl() if err != nil { - return ApiError(500, "Failed to get dashboard acl", err) + return ApiError(500, "Failed to get dashboard permissions", err) } for _, perm := range acl { @@ -38,7 +38,7 @@ func GetDashboardAclList(c *middleware.Context) Response { return Json(200, acl) } -func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { +func UpdateDashboardPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { dashId := c.ParamsInt64(":dashboardId") _, rsp := getDashboardHelper(c.OrgId, "", dashId, "") @@ -82,5 +82,5 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom return ApiError(500, "Failed to create permission", err) } - return ApiSuccess("Dashboard acl updated") + return ApiSuccess("Dashboard permissions updated") } diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_permission_test.go similarity index 82% rename from pkg/api/dashboard_acl_test.go rename to pkg/api/dashboard_permission_test.go index d6b7e305daf..fd399cfb096 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -12,8 +12,8 @@ import ( . "github.com/smartystreets/goconvey/convey" ) -func TestDashboardAclApiEndpoint(t *testing.T) { - Convey("Given a dashboard acl", t, func() { +func TestDashboardPermissionApiEndpoint(t *testing.T) { + Convey("Given a dashboard with permissions", t, func() { mockResult := []*m.DashboardAclInfoDTO{ {OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW}, {OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT}, @@ -54,9 +54,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) { // 4. user is an org editor AND has no permissions for the dashboard Convey("When user is org admin", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardsId/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { Convey("Should be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList + sc.handlerFunc = GetDashboardPermissionList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) @@ -69,9 +69,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { getDashboardNotFoundError = m.ErrDashboardNotFound - sc.handlerFunc = GetDashboardAclList + sc.handlerFunc = GetDashboardPermissionList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() Convey("Should not be able to access ACL", func() { @@ -86,7 +86,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }, } - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) { + postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) { getDashboardNotFoundError = m.ErrDashboardNotFound CallPostAcl(sc) So(sc.resp.Code, ShouldEqual, 404) @@ -95,11 +95,11 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) Convey("When user is org editor and has admin permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) Convey("Should be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList + sc.handlerFunc = GetDashboardPermissionList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 200) @@ -113,7 +113,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }, } - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) CallPostAcl(sc) @@ -129,7 +129,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }, } - postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) CallPostAcl(sc) @@ -140,12 +140,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) Convey("When user is org viewer and has edit permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) // Getting the permissions is an Admin permission Convey("Should not be able to get list of permissions from ACL", func() { - sc.handlerFunc = GetDashboardAclList + sc.handlerFunc = GetDashboardPermissionList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 403) @@ -154,10 +154,10 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) Convey("When user is org editor and not in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardsId/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { Convey("Should not be able to access ACL", func() { - sc.handlerFunc = GetDashboardAclList + sc.handlerFunc = GetDashboardPermissionList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() So(sc.resp.Code, ShouldEqual, 403) @@ -204,7 +204,7 @@ func postAclScenario(desc string, url string, routePattern string, role m.RoleTy sc.context.OrgId = TestOrgID sc.context.OrgRole = role - return UpdateDashboardAcl(c, cmd) + return UpdateDashboardPermissions(c, cmd) }) sc.m.Post(routePattern, sc.defaultHandler) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 046075fc622..734df3da00d 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -746,8 +746,7 @@ func TestDashboardApiEndpoint(t *testing.T) { } func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { - sc.handlerFunc = GetDashboard - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + CallGetDashboard(sc) So(sc.resp.Code, ShouldEqual, 200) @@ -758,6 +757,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta return dash } +func CallGetDashboard(sc *scenarioContext) { + sc.handlerFunc = GetDashboard + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + func CallGetDashboardVersion(sc *scenarioContext) { bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error { query.Result = &m.DashboardVersion{} diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go new file mode 100644 index 00000000000..469656c6f8f --- /dev/null +++ b/pkg/api/dtos/folder.go @@ -0,0 +1,25 @@ +package dtos + +import "time" + +type Folder struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` + Url string `json:"url"` + HasAcl bool `json:"hasAcl"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CreatedBy string `json:"createdBy"` + Created time.Time `json:"created"` + UpdatedBy string `json:"updatedBy"` + Updated time.Time `json:"updated"` + Version int `json:"version"` +} + +type FolderSearchHit struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` +} diff --git a/pkg/api/folder.go b/pkg/api/folder.go new file mode 100644 index 00000000000..e3c4f127569 --- /dev/null +++ b/pkg/api/folder.go @@ -0,0 +1,147 @@ +package api + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/util" +) + +func GetFolders(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folders, err := s.GetFolders(c.QueryInt("limit")) + + if err != nil { + return toFolderError(err) + } + + result := make([]dtos.FolderSearchHit, 0) + + for _, f := range folders { + result = append(result, dtos.FolderSearchHit{ + Id: f.Id, + Uid: f.Uid, + Title: f.Title, + }) + } + + return Json(200, result) +} + +func GetFolderByUid(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUid(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, folder)) +} + +func GetFolderById(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderById(c.ParamsInt64(":id")) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, folder)) +} + +func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + err := s.CreateFolder(&cmd) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, cmd.Result)) +} + +func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + err := s.UpdateFolder(c.Params(":uid"), &cmd) + if err != nil { + return toFolderError(err) + } + + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) + return Json(200, toFolderDto(g, cmd.Result)) +} + +func DeleteFolder(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + f, err := s.DeleteFolder(c.Params(":uid")) + if err != nil { + return toFolderError(err) + } + + return Json(200, util.DynMap{ + "title": f.Title, + "message": fmt.Sprintf("Folder %s deleted", f.Title), + }) +} + +func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder { + canEdit, _ := g.CanEdit() + canSave, _ := g.CanSave() + canAdmin, _ := g.CanAdmin() + + // Finding creator and last updater of the folder + updater, creator := "Anonymous", "Anonymous" + if folder.CreatedBy > 0 { + creator = getUserLogin(folder.CreatedBy) + } + if folder.UpdatedBy > 0 { + updater = getUserLogin(folder.UpdatedBy) + } + + return dtos.Folder{ + Id: folder.Id, + Uid: folder.Uid, + Title: folder.Title, + Url: folder.Url, + HasAcl: folder.HasAcl, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + Version: folder.Version, + } +} + +func toFolderError(err error) Response { + if err == m.ErrFolderTitleEmpty || + err == m.ErrFolderSameNameExists || + err == m.ErrFolderWithSameUIDExists || + err == m.ErrDashboardTypeMismatch || + err == m.ErrDashboardInvalidUid || + err == m.ErrDashboardUidToLong { + return ApiError(400, err.Error(), nil) + } + + if err == m.ErrFolderAccessDenied { + return ApiError(403, "Access denied", err) + } + + if err == m.ErrFolderNotFound { + return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()}) + } + + if err == m.ErrFolderVersionMismatch { + return Json(412, util.DynMap{"status": "version-mismatch", "message": m.ErrFolderVersionMismatch.Error()}) + } + + return ApiError(500, "Folder API error", err) +} diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go new file mode 100644 index 00000000000..7453552d092 --- /dev/null +++ b/pkg/api/folder_permission.go @@ -0,0 +1,103 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" +) + +func GetFolderPermissionList(c *middleware.Context) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUid(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + guardian := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + + if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin { + return toFolderError(m.ErrFolderAccessDenied) + } + + acl, err := guardian.GetAcl() + if err != nil { + return ApiError(500, "Failed to get folder permissions", err) + } + + for _, perm := range acl { + perm.FolderId = folder.Id + perm.DashboardId = 0 + + if perm.Slug != "" { + perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug) + } + } + + return Json(200, acl) +} + +func UpdateFolderPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response { + s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) + folder, err := s.GetFolderByUid(c.Params(":uid")) + + if err != nil { + return toFolderError(err) + } + + guardian := guardian.New(folder.Id, c.OrgId, c.SignedInUser) + canAdmin, err := guardian.CanAdmin() + if err != nil { + return toFolderError(err) + } + + if !canAdmin { + return toFolderError(m.ErrFolderAccessDenied) + } + + cmd := m.UpdateDashboardAclCommand{} + cmd.DashboardId = folder.Id + + for _, item := range apiCmd.Items { + cmd.Items = append(cmd.Items, &m.DashboardAcl{ + OrgId: c.OrgId, + DashboardId: folder.Id, + UserId: item.UserId, + TeamId: item.TeamId, + Role: item.Role, + Permission: item.Permission, + Created: time.Now(), + Updated: time.Now(), + }) + } + + if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate { + if err != nil { + return ApiError(500, "Error while checking folder permissions", err) + } + + return ApiError(403, "Cannot remove own admin permission for a folder", nil) + } + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrDashboardAclInfoMissing { + err = m.ErrFolderAclInfoMissing + } + if err == m.ErrDashboardPermissionDashboardEmpty { + err = m.ErrFolderPermissionFolderEmpty + } + + if err == m.ErrFolderAclInfoMissing || err == m.ErrFolderPermissionFolderEmpty { + return ApiError(409, err.Error(), err) + } + + return ApiError(500, "Failed to create permission", err) + } + + return ApiSuccess("Folder permissions updated") +} diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go new file mode 100644 index 00000000000..bbae5390b80 --- /dev/null +++ b/pkg/api/folder_permission_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFolderPermissionApiEndpoint(t *testing.T) { + Convey("Folder permissions test", t, func() { + Convey("Given user has no admin permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) + + mock := &fakeFolderService{ + GetFolderByUidResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + + Convey("Given user has admin permissions and permissions to update", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: true, CheckPermissionBeforeUpdateValue: true}) + + mock := &fakeFolderService{ + GetFolderByUidResult: &m.Folder{ + Id: 1, + Uid: "uid", + Title: "Folder", + }, + } + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) { + callGetFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + cmd := dtos.UpdateDashboardAclCommand{ + Items: []dtos.DashboardAclUpdateItem{ + {UserId: 1000, Permission: m.PERMISSION_ADMIN}, + }, + } + + updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) { + callUpdateFolderPermissions(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + + Reset(func() { + guardian.New = origNewGuardian + dashboards.NewFolderService = origNewFolderService + }) + }) + }) +} + +func callGetFolderPermissions(sc *scenarioContext) { + sc.handlerFunc = GetFolderPermissionList + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callUpdateFolderPermissions(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func updateFolderPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.OrgId = TestOrgID + sc.context.UserId = TestUserID + + return UpdateFolderPermissions(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go new file mode 100644 index 00000000000..0ab3cc7d7c9 --- /dev/null +++ b/pkg/api/folder_test.go @@ -0,0 +1,254 @@ +package api + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFoldersApiEndpoint(t *testing.T) { + Convey("Create/update folder response tests", t, func() { + Convey("Given a correct request for creating a folder", func() { + cmd := m.CreateFolderCommand{ + Uid: "uid", + Title: "Folder", + } + + mock := &fakeFolderService{ + CreateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder"}, + } + + createFolderScenario("When calling POST on", "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + + Convey("It should return correct response data", func() { + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + So(folder.Id, ShouldEqual, 1) + So(folder.Uid, ShouldEqual, "uid") + So(folder.Title, ShouldEqual, "Folder") + }) + }) + }) + + Convey("Given incorrect requests for creating a folder", func() { + testCases := []struct { + Error error + ExpectedStatusCode int + }{ + {Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400}, + {Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400}, + {Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403}, + {Error: m.ErrFolderNotFound, ExpectedStatusCode: 404}, + {Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412}, + {Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + } + + cmd := m.CreateFolderCommand{ + Uid: "uid", + Title: "Folder", + } + + for _, tc := range testCases { + mock := &fakeFolderService{ + CreateFolderError: tc.Error, + } + + createFolderScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()), "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + callCreateFolder(sc) + if sc.resp.Code != tc.ExpectedStatusCode { + t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code) + } + }) + } + }) + + Convey("Given a correct request for updating a folder", func() { + cmd := m.UpdateFolderCommand{ + Title: "Folder upd", + } + + mock := &fakeFolderService{ + UpdateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder upd"}, + } + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + + Convey("It should return correct response data", func() { + folder := dtos.Folder{} + err := json.NewDecoder(sc.resp.Body).Decode(&folder) + So(err, ShouldBeNil) + So(folder.Id, ShouldEqual, 1) + So(folder.Uid, ShouldEqual, "uid") + So(folder.Title, ShouldEqual, "Folder upd") + }) + }) + }) + + Convey("Given incorrect requests for updating a folder", func() { + testCases := []struct { + Error error + ExpectedStatusCode int + }{ + {Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400}, + {Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400}, + {Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400}, + {Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400}, + {Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403}, + {Error: m.ErrFolderNotFound, ExpectedStatusCode: 404}, + {Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412}, + {Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500}, + } + + cmd := m.UpdateFolderCommand{ + Title: "Folder upd", + } + + for _, tc := range testCases { + mock := &fakeFolderService{ + UpdateFolderError: tc.Error, + } + + updateFolderScenario(fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()), "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + if sc.resp.Code != tc.ExpectedStatusCode { + t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code) + } + }) + } + }) + }) +} + +func callGetFolderByUid(sc *scenarioContext) { + sc.handlerFunc = GetFolderByUid + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() +} + +func callDeleteFolder(sc *scenarioContext) { + sc.handlerFunc = DeleteFolder + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() +} + +func callCreateFolder(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func createFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.CreateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} + + return CreateFolder(c, cmd) + }) + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + sc.m.Post(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewFolderService = origNewFolderService + }() + + fn(sc) + }) +} + +func callUpdateFolder(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() +} + +func updateFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.UpdateFolderCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} + + return UpdateFolder(c, cmd) + }) + + origNewFolderService := dashboards.NewFolderService + mockFolderService(mock) + + sc.m.Put(routePattern, sc.defaultHandler) + + defer func() { + dashboards.NewFolderService = origNewFolderService + }() + + fn(sc) + }) +} + +type fakeFolderService struct { + GetFoldersResult []*models.Folder + GetFoldersError error + GetFolderByUidResult *models.Folder + GetFolderByUidError error + GetFolderByIdResult *models.Folder + GetFolderByIdError error + CreateFolderResult *models.Folder + CreateFolderError error + UpdateFolderResult *models.Folder + UpdateFolderError error + DeleteFolderResult *models.Folder + DeleteFolderError error + DeletedFolderUids []string +} + +func (s *fakeFolderService) GetFolders(limit int) ([]*models.Folder, error) { + return s.GetFoldersResult, s.GetFoldersError +} + +func (s *fakeFolderService) GetFolderById(id int64) (*models.Folder, error) { + return s.GetFolderByIdResult, s.GetFolderByIdError +} + +func (s *fakeFolderService) GetFolderByUid(uid string) (*models.Folder, error) { + return s.GetFolderByUidResult, s.GetFolderByUidError +} + +func (s *fakeFolderService) CreateFolder(cmd *models.CreateFolderCommand) error { + cmd.Result = s.CreateFolderResult + return s.CreateFolderError +} + +func (s *fakeFolderService) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error { + cmd.Result = s.UpdateFolderResult + return s.UpdateFolderError +} + +func (s *fakeFolderService) DeleteFolder(uid string) (*models.Folder, error) { + s.DeletedFolderUids = append(s.DeletedFolderUids, uid) + return s.DeleteFolderResult, s.DeleteFolderError +} + +func mockFolderService(mock *fakeFolderService) { + dashboards.NewFolderService = func(orgId int64, user *models.SignedInUser) dashboards.FolderService { + return mock + } +} diff --git a/pkg/models/dashboard_acl.go b/pkg/models/dashboard_acl.go index 202b519207d..1fbd2b451b9 100644 --- a/pkg/models/dashboard_acl.go +++ b/pkg/models/dashboard_acl.go @@ -26,6 +26,8 @@ func (p PermissionType) String() string { var ( ErrDashboardAclInfoMissing = errors.New("User id and team id cannot both be empty for a dashboard permission.") ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.") + ErrFolderAclInfoMissing = errors.New("User id and team id cannot both be empty for a folder permission.") + ErrFolderPermissionFolderEmpty = errors.New("Folder Id must be greater than zero for a folder permission.") ) // Dashboard ACL model @@ -45,7 +47,8 @@ type DashboardAcl struct { type DashboardAclInfoDTO struct { OrgId int64 `json:"-"` - DashboardId int64 `json:"dashboardId"` + DashboardId int64 `json:"dashboardId,omitempty"` + FolderId int64 `json:"folderId,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 5bf37136548..4b771038df6 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -14,7 +14,7 @@ import ( // Typed errors var ( ErrDashboardNotFound = errors.New("Dashboard not found") - ErrFolderNotFound = errors.New("Folder not found") + ErrDashboardFolderNotFound = errors.New("Folder not found") ErrDashboardSnapshotNotFound = errors.New("Dashboard snapshot not found") ErrDashboardWithSameUIDExists = errors.New("A dashboard with the same uid already exists") ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists") @@ -112,9 +112,9 @@ func NewDashboard(title string) *Dashboard { // NewDashboardFolder creates a new dashboard folder func NewDashboardFolder(title string) *Dashboard { folder := NewDashboard(title) + folder.IsFolder = true folder.Data.Set("schemaVersion", 16) - folder.Data.Set("editable", true) - folder.Data.Set("hideControls", true) + folder.Data.Set("version", 0) folder.IsFolder = true return folder } @@ -166,10 +166,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { userId = -1 } - if dash.Data.Get("version").MustInt(0) == 0 { - dash.CreatedBy = userId - } - dash.UpdatedBy = userId dash.OrgId = cmd.OrgId dash.PluginId = cmd.PluginId diff --git a/pkg/models/folders.go b/pkg/models/folders.go new file mode 100644 index 00000000000..c61620a11fc --- /dev/null +++ b/pkg/models/folders.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "strings" + "time" +) + +// Typed errors +var ( + ErrFolderNotFound = errors.New("Folder not found") + ErrFolderVersionMismatch = errors.New("The folder has been changed by someone else") + ErrFolderTitleEmpty = errors.New("Folder title cannot be empty") + ErrFolderWithSameUIDExists = errors.New("A folder/dashboard with the same uid already exists") + ErrFolderSameNameExists = errors.New("A folder or dashboard in the general folder with the same name already exists") + ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id") + ErrFolderAccessDenied = errors.New("Access denied to folder") +) + +type Folder struct { + Id int64 + Uid string + Title string + Url string + Version int + + Created time.Time + Updated time.Time + + UpdatedBy int64 + CreatedBy int64 + HasAcl bool +} + +// GetDashboardModel turns the command into the savable model +func (cmd *CreateFolderCommand) GetDashboardModel(orgId int64, userId int64) *Dashboard { + dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title)) + dashFolder.OrgId = orgId + dashFolder.SetUid(strings.TrimSpace(cmd.Uid)) + + if userId == 0 { + userId = -1 + } + + dashFolder.CreatedBy = userId + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() + + return dashFolder +} + +// UpdateDashboardModel updates an existing model from command into model for update +func (cmd *UpdateFolderCommand) UpdateDashboardModel(dashFolder *Dashboard, orgId int64, userId int64) { + dashFolder.OrgId = orgId + dashFolder.Title = strings.TrimSpace(cmd.Title) + dashFolder.Data.Set("title", dashFolder.Title) + + if cmd.Uid != "" { + dashFolder.SetUid(cmd.Uid) + } + + dashFolder.SetVersion(cmd.Version) + dashFolder.IsFolder = true + + if userId == 0 { + userId = -1 + } + + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() +} + +// +// COMMANDS +// + +type CreateFolderCommand struct { + Uid string `json:"uid"` + Title string `json:"title"` + + Result *Folder +} + +type UpdateFolderCommand struct { + Uid string `json:"uid"` + Title string `json:"title"` + Version int `json:"version"` + Overwrite bool `json:"overwrite"` + + Result *Folder +} diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index baf8131f8fd..1f39394d757 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -41,7 +41,10 @@ type SaveDashboardDTO struct { Dashboard *models.Dashboard } -type dashboardServiceImpl struct{} +type dashboardServiceImpl struct { + orgId int64 + user *models.SignedInUser +} func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { cmd := &models.GetProvisionedDashboardDataQuery{Name: name} @@ -53,7 +56,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod return cmd.Result, nil } -func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) { +func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) { dash := dto.Dashboard dash.Title = strings.TrimSpace(dash.Title) @@ -78,13 +81,15 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) return nil, models.ErrDashboardUidToLong } - validateAlertsCmd := models.ValidateDashboardAlertsCommand{ - OrgId: dto.OrgId, - Dashboard: dash, - } + if validateAlerts { + validateAlertsCmd := models.ValidateDashboardAlertsCommand{ + OrgId: dto.OrgId, + Dashboard: dash, + } - if err := bus.Dispatch(&validateAlertsCmd); err != nil { - return nil, models.ErrDashboardContainsInvalidAlertData + if err := bus.Dispatch(&validateAlertsCmd); err != nil { + return nil, models.ErrDashboardContainsInvalidAlertData + } } validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{ @@ -142,7 +147,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(dto) + cmd, err := dr.buildSaveDashboardCommand(dto, true) if err != nil { return nil, err } @@ -172,7 +177,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(dto) + cmd, err := dr.buildSaveDashboardCommand(dto, false) if err != nil { return nil, err } @@ -191,7 +196,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash } func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) { - cmd, err := dr.buildSaveDashboardCommand(dto) + cmd, err := dr.buildSaveDashboardCommand(dto, true) if err != nil { return nil, err } diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go index 4a7dba762f6..965b10655b3 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -17,7 +17,7 @@ func TestDashboardService(t *testing.T) { service := dashboardServiceImpl{} origNewDashboardGuardian := guardian.New - mockDashboardGuardian(&fakeDashboardGuardian{canSave: true}) + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) Convey("Save dashboard validation", func() { dto := &SaveDashboardDTO{} @@ -72,7 +72,7 @@ func TestDashboardService(t *testing.T) { dto.Dashboard.SetUid(tc.Uid) dto.User = &models.SignedInUser{} - _, err := service.buildSaveDashboardCommand(dto) + _, err := service.buildSaveDashboardCommand(dto, true) So(err, ShouldEqual, tc.Error) } }) @@ -93,52 +93,3 @@ func TestDashboardService(t *testing.T) { }) }) } - -func mockDashboardGuardian(mock *fakeDashboardGuardian) { - guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian { - mock.orgId = orgId - mock.dashId = dashId - mock.user = user - return mock - } -} - -type fakeDashboardGuardian struct { - dashId int64 - orgId int64 - user *models.SignedInUser - canSave bool - canEdit bool - canView bool - canAdmin bool - hasPermission bool - checkPermissionBeforeUpdate bool -} - -func (g *fakeDashboardGuardian) CanSave() (bool, error) { - return g.canSave, nil -} - -func (g *fakeDashboardGuardian) CanEdit() (bool, error) { - return g.canEdit, nil -} - -func (g *fakeDashboardGuardian) CanView() (bool, error) { - return g.canView, nil -} - -func (g *fakeDashboardGuardian) CanAdmin() (bool, error) { - return g.canAdmin, nil -} - -func (g *fakeDashboardGuardian) HasPermission(permission models.PermissionType) (bool, error) { - return g.hasPermission, nil -} - -func (g *fakeDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) { - return g.checkPermissionBeforeUpdate, nil -} - -func (g *fakeDashboardGuardian) GetAcl() ([]*models.DashboardAclInfoDTO, error) { - return nil, nil -} diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go new file mode 100644 index 00000000000..66afa6306fb --- /dev/null +++ b/pkg/services/dashboards/folder_service.go @@ -0,0 +1,245 @@ +package dashboards + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/search" +) + +// FolderService service for operating on folders +type FolderService interface { + GetFolders(limit int) ([]*models.Folder, error) + GetFolderById(id int64) (*models.Folder, error) + GetFolderByUid(uid string) (*models.Folder, error) + CreateFolder(cmd *models.CreateFolderCommand) error + UpdateFolder(uid string, cmd *models.UpdateFolderCommand) error + DeleteFolder(uid string) (*models.Folder, error) +} + +// NewFolderService factory for creating a new folder service +var NewFolderService = func(orgId int64, user *models.SignedInUser) FolderService { + return &dashboardServiceImpl{ + orgId: orgId, + user: user, + } +} + +func (dr *dashboardServiceImpl) GetFolders(limit int) ([]*models.Folder, error) { + if limit == 0 { + limit = 1000 + } + + searchQuery := search.Query{ + SignedInUser: dr.user, + DashboardIds: make([]int64, 0), + FolderIds: make([]int64, 0), + Limit: limit, + OrgId: dr.orgId, + Type: "dash-folder", + Permission: models.PERMISSION_VIEW, + } + + if err := bus.Dispatch(&searchQuery); err != nil { + return nil, err + } + + folders := make([]*models.Folder, 0) + + for _, hit := range searchQuery.Result { + folders = append(folders, &models.Folder{ + Id: hit.Id, + Uid: hit.Uid, + Title: hit.Title, + }) + } + + return folders, nil +} + +func (dr *dashboardServiceImpl) GetFolderById(id int64) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id} + dashFolder, err := getFolder(query) + + if err != nil { + return nil, toFolderError(err) + } + + g := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + return dashToFolder(dashFolder), nil +} + +func (dr *dashboardServiceImpl) GetFolderByUid(uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} + dashFolder, err := getFolder(query) + + if err != nil { + return nil, toFolderError(err) + } + + g := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + return dashToFolder(dashFolder), nil +} + +func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) error { + dashFolder := cmd.GetDashboardModel(dr.orgId, dr.user.UserId) + + dto := &SaveDashboardDTO{ + Dashboard: dashFolder, + OrgId: dr.orgId, + User: dr.user, + } + + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return toFolderError(err) + } + + err = bus.Dispatch(saveDashboardCmd) + if err != nil { + return toFolderError(err) + } + + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} + dashFolder, err = getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.Result = dashToFolder(dashFolder) + + return nil +} + +func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid} + dashFolder, err := getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId) + + dto := &SaveDashboardDTO{ + Dashboard: dashFolder, + OrgId: dr.orgId, + User: dr.user, + Overwrite: cmd.Overwrite, + } + + saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false) + if err != nil { + return toFolderError(err) + } + + err = bus.Dispatch(saveDashboardCmd) + if err != nil { + return toFolderError(err) + } + + query = models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} + dashFolder, err = getFolder(query) + if err != nil { + return toFolderError(err) + } + + cmd.Result = dashToFolder(dashFolder) + + return nil +} + +func (dr *dashboardServiceImpl) DeleteFolder(uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} + dashFolder, err := getFolder(query) + if err != nil { + return nil, toFolderError(err) + } + + guardian := guardian.New(dashFolder.Id, dr.orgId, dr.user) + if canSave, err := guardian.CanSave(); err != nil || !canSave { + if err != nil { + return nil, toFolderError(err) + } + return nil, models.ErrFolderAccessDenied + } + + deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id} + if err := bus.Dispatch(&deleteCmd); err != nil { + return nil, toFolderError(err) + } + + return dashToFolder(dashFolder), nil +} + +func getFolder(query models.GetDashboardQuery) (*models.Dashboard, error) { + if err := bus.Dispatch(&query); err != nil { + return nil, toFolderError(err) + } + + if !query.Result.IsFolder { + return nil, models.ErrFolderNotFound + } + + return query.Result, nil +} + +func dashToFolder(dash *models.Dashboard) *models.Folder { + return &models.Folder{ + Id: dash.Id, + Uid: dash.Uid, + Title: dash.Title, + HasAcl: dash.HasAcl, + Url: dash.GetUrl(), + Version: dash.Version, + Created: dash.Created, + CreatedBy: dash.CreatedBy, + Updated: dash.Updated, + UpdatedBy: dash.UpdatedBy, + } +} + +func toFolderError(err error) error { + if err == models.ErrDashboardTitleEmpty { + return models.ErrFolderTitleEmpty + } + + if err == models.ErrDashboardUpdateAccessDenied { + return models.ErrFolderAccessDenied + } + + if err == models.ErrDashboardWithSameNameInFolderExists { + return models.ErrFolderSameNameExists + } + + if err == models.ErrDashboardWithSameUIDExists { + return models.ErrFolderWithSameUIDExists + } + + if err == models.ErrDashboardVersionMismatch { + return models.ErrFolderVersionMismatch + } + + if err == models.ErrDashboardNotFound { + return models.ErrFolderNotFound + } + + if err == models.ErrDashboardFailedGenerateUniqueUid { + err = models.ErrFolderFailedGenerateUniqueUid + } + + return err +} diff --git a/pkg/services/dashboards/folder_service_test.go b/pkg/services/dashboards/folder_service_test.go new file mode 100644 index 00000000000..6357e84805a --- /dev/null +++ b/pkg/services/dashboards/folder_service_test.go @@ -0,0 +1,191 @@ +package dashboards + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + + "github.com/grafana/grafana/pkg/services/guardian" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestFolderService(t *testing.T) { + Convey("Folder service tests", t, func() { + service := dashboardServiceImpl{ + orgId: 1, + user: &models.SignedInUser{UserId: 1}, + } + + Convey("Given user has no permissions", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = models.NewDashboardFolder("Folder") + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return models.ErrDashboardUpdateAccessDenied + }) + + Convey("When get folder by id should return access denied error", func() { + _, err := service.GetFolderById(1) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When get folder by uid should return access denied error", func() { + _, err := service.GetFolderByUid("uid") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When creating folder should return access denied error", func() { + err := service.CreateFolder(&models.CreateFolderCommand{ + Title: "Folder", + }) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When updating folder should return access denied error", func() { + err := service.UpdateFolder("uid", &models.UpdateFolderCommand{ + Uid: "uid", + Title: "Folder", + }) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Convey("When deleting folder by uid should return access denied error", func() { + _, err := service.DeleteFolder("uid") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, models.ErrFolderAccessDenied) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has permission to save", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + + dash := models.NewDashboardFolder("Folder") + dash.Id = 1 + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = dash + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.SaveDashboardCommand) error { + cmd.Result = dash + return nil + }) + + bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error { + return nil + }) + + Convey("When creating folder should not return access denied error", func() { + err := service.CreateFolder(&models.CreateFolderCommand{ + Title: "Folder", + }) + So(err, ShouldBeNil) + }) + + Convey("When updating folder should not return access denied error", func() { + err := service.UpdateFolder("uid", &models.UpdateFolderCommand{ + Uid: "uid", + Title: "Folder", + }) + So(err, ShouldBeNil) + }) + + Convey("When deleting folder by uid should not return access denied error", func() { + _, err := service.DeleteFolder("uid") + So(err, ShouldBeNil) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Given user has permission to view", func() { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) + + dashFolder := models.NewDashboardFolder("Folder") + dashFolder.Id = 1 + dashFolder.Uid = "uid-abc" + + bus.AddHandler("test", func(query *models.GetDashboardQuery) error { + query.Result = dashFolder + return nil + }) + + Convey("When get folder by id should return folder", func() { + f, _ := service.GetFolderById(1) + So(f.Id, ShouldEqual, dashFolder.Id) + So(f.Uid, ShouldEqual, dashFolder.Uid) + So(f.Title, ShouldEqual, dashFolder.Title) + }) + + Convey("When get folder by uid should return folder", func() { + f, _ := service.GetFolderByUid("uid") + So(f.Id, ShouldEqual, dashFolder.Id) + So(f.Uid, ShouldEqual, dashFolder.Uid) + So(f.Title, ShouldEqual, dashFolder.Title) + }) + + Reset(func() { + guardian.New = origNewGuardian + }) + }) + + Convey("Should map errors correct", func() { + testCases := []struct { + ActualError error + ExpectedError error + }{ + {ActualError: models.ErrDashboardTitleEmpty, ExpectedError: models.ErrFolderTitleEmpty}, + {ActualError: models.ErrDashboardUpdateAccessDenied, ExpectedError: models.ErrFolderAccessDenied}, + {ActualError: models.ErrDashboardWithSameNameInFolderExists, ExpectedError: models.ErrFolderSameNameExists}, + {ActualError: models.ErrDashboardWithSameUIDExists, ExpectedError: models.ErrFolderWithSameUIDExists}, + {ActualError: models.ErrDashboardVersionMismatch, ExpectedError: models.ErrFolderVersionMismatch}, + {ActualError: models.ErrDashboardNotFound, ExpectedError: models.ErrFolderNotFound}, + {ActualError: models.ErrDashboardFailedGenerateUniqueUid, ExpectedError: models.ErrFolderFailedGenerateUniqueUid}, + {ActualError: models.ErrDashboardInvalidUid, ExpectedError: models.ErrDashboardInvalidUid}, + } + + for _, tc := range testCases { + actualError := toFolderError(tc.ActualError) + if actualError != tc.ExpectedError { + t.Errorf("For error '%s' expected error '%s', actual '%s'", tc.ActualError, tc.ExpectedError, actualError) + } + } + }) + }) +} diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 98db5449182..23d43a53f35 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -158,3 +158,52 @@ func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) { g.groups = query.Result return query.Result, err } + +type FakeDashboardGuardian struct { + DashId int64 + OrgId int64 + User *m.SignedInUser + CanSaveValue bool + CanEditValue bool + CanViewValue bool + CanAdminValue bool + HasPermissionValue bool + CheckPermissionBeforeUpdateValue bool +} + +func (g *FakeDashboardGuardian) CanSave() (bool, error) { + return g.CanSaveValue, nil +} + +func (g *FakeDashboardGuardian) CanEdit() (bool, error) { + return g.CanEditValue, nil +} + +func (g *FakeDashboardGuardian) CanView() (bool, error) { + return g.CanViewValue, nil +} + +func (g *FakeDashboardGuardian) CanAdmin() (bool, error) { + return g.CanAdminValue, nil +} + +func (g *FakeDashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) { + return g.HasPermissionValue, nil +} + +func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) { + return g.CheckPermissionBeforeUpdateValue, nil +} + +func (g *FakeDashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) { + return nil, nil +} + +func MockDashboardGuardian(mock *FakeDashboardGuardian) { + New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian { + mock.OrgId = orgId + mock.DashId = dashId + mock.User = user + return mock + } +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 8516e7c46e7..5ee34183628 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -37,6 +37,12 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error { func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { dash := cmd.GetDashboardModel() + userId := cmd.UserId + + if userId == 0 { + userId = -1 + } + if dash.Id > 0 { var existing m.Dashboard dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) @@ -76,17 +82,23 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error { if dash.Id == 0 { dash.SetVersion(1) + dash.Created = time.Now() + dash.CreatedBy = userId + dash.Updated = time.Now() + dash.UpdatedBy = userId metrics.M_Api_Dashboard_Insert.Inc() affectedRows, err = sess.Insert(dash) } else { - v := dash.Version - v++ - dash.SetVersion(v) + dash.SetVersion(dash.Version + 1) if !cmd.UpdatedAt.IsZero() { dash.Updated = cmd.UpdatedAt + } else { + dash.Updated = time.Now() } + dash.UpdatedBy = userId + affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) } @@ -514,7 +526,7 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash } if !folderExists { - return m.ErrFolderNotFound + return m.ErrDashboardFolderNotFound } } diff --git a/pkg/services/sqlstore/dashboard_service_integration_test.go b/pkg/services/sqlstore/dashboard_service_integration_test.go index e4bf93c2f58..d005270c33c 100644 --- a/pkg/services/sqlstore/dashboard_service_integration_test.go +++ b/pkg/services/sqlstore/dashboard_service_integration_test.go @@ -142,9 +142,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, 0) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) @@ -165,9 +165,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, otherSavedFolder.Id) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) @@ -189,9 +189,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInGeneralFolder.Id) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInGeneralFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) @@ -213,9 +213,9 @@ func TestIntegratedDashboardService(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied) - So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInFolder.Id) - So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId) - So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId) + So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInFolder.Id) + So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId) + So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId) }) }) }) @@ -363,7 +363,7 @@ func TestIntegratedDashboardService(t *testing.T) { Convey("It should result in folder not found error", func() { So(err, ShouldNotBeNil) - So(err, ShouldEqual, models.ErrFolderNotFound) + So(err, ShouldEqual, models.ErrDashboardFolderNotFound) }) }) @@ -785,68 +785,16 @@ func TestIntegratedDashboardService(t *testing.T) { }) } -func mockDashboardGuardian(mock *mockDashboardGuarder) { - guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian { - mock.orgId = orgId - mock.dashId = dashId - mock.user = user - return mock - } -} - -type mockDashboardGuarder struct { - dashId int64 - orgId int64 - user *models.SignedInUser - canSave bool - canSaveCallCounter int - canEdit bool - canView bool - canAdmin bool - hasPermission bool - checkPermissionBeforeRemove bool - checkPermissionBeforeUpdate bool -} - -func (g *mockDashboardGuarder) CanSave() (bool, error) { - g.canSaveCallCounter++ - return g.canSave, nil -} - -func (g *mockDashboardGuarder) CanEdit() (bool, error) { - return g.canEdit, nil -} - -func (g *mockDashboardGuarder) CanView() (bool, error) { - return g.canView, nil -} - -func (g *mockDashboardGuarder) CanAdmin() (bool, error) { - return g.canAdmin, nil -} - -func (g *mockDashboardGuarder) HasPermission(permission models.PermissionType) (bool, error) { - return g.hasPermission, nil -} - -func (g *mockDashboardGuarder) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) { - return g.checkPermissionBeforeUpdate, nil -} - -func (g *mockDashboardGuarder) GetAcl() ([]*models.DashboardAclInfoDTO, error) { - return nil, nil -} - type scenarioContext struct { - dashboardGuardianMock *mockDashboardGuarder + dashboardGuardianMock *guardian.FakeDashboardGuardian } type scenarioFunc func(c *scenarioContext) -func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) { +func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) { Convey(desc, func() { origNewDashboardGuardian := guardian.New - mockDashboardGuardian(mock) + guardian.MockDashboardGuardian(mock) sc := &scenarioContext{ dashboardGuardianMock: mock, @@ -861,15 +809,15 @@ func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scena } type dashboardPermissionScenarioContext struct { - dashboardGuardianMock *mockDashboardGuarder + dashboardGuardianMock *guardian.FakeDashboardGuardian } type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext) -func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn dashboardPermissionScenarioFunc) { +func dashboardPermissionScenario(desc string, mock *guardian.FakeDashboardGuardian, fn dashboardPermissionScenarioFunc) { Convey(desc, func() { origNewDashboardGuardian := guardian.New - mockDashboardGuardian(mock) + guardian.MockDashboardGuardian(mock) sc := &dashboardPermissionScenarioContext{ dashboardGuardianMock: mock, @@ -884,8 +832,8 @@ func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn das } func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) { - mock := &mockDashboardGuarder{ - canSave: canSave, + mock := &guardian.FakeDashboardGuardian{ + CanSaveValue: canSave, } dashboardPermissionScenario(desc, mock, fn) } @@ -902,10 +850,10 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error { return err } -func dashboardServiceScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) { +func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) { Convey(desc, func() { origNewDashboardGuardian := guardian.New - mockDashboardGuardian(mock) + guardian.MockDashboardGuardian(mock) sc := &scenarioContext{ dashboardGuardianMock: mock, diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index 51a2d4eb64e..e0a73b9a49a 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -3,6 +3,7 @@ package sqlstore import ( "fmt" "testing" + "time" "github.com/go-xorm/xorm" "github.com/grafana/grafana/pkg/components/simplejson" @@ -124,6 +125,24 @@ func TestDashboardDataAccess(t *testing.T) { generateNewUid = util.GenerateShortUid }) + Convey("Should be able to create dashboard", func() { + cmd := m.SaveDashboardCommand{ + OrgId: 1, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "folderId", + "tags": []interface{}{}, + }), + UserId: 100, + } + + err := SaveDashboard(&cmd) + So(err, ShouldBeNil) + So(cmd.Result.CreatedBy, ShouldEqual, 100) + So(cmd.Result.Created.IsZero(), ShouldBeFalse) + So(cmd.Result.UpdatedBy, ShouldEqual, 100) + So(cmd.Result.Updated.IsZero(), ShouldBeFalse) + }) + Convey("Should be able to update dashboard by id and remove folderId", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, @@ -134,6 +153,7 @@ func TestDashboardDataAccess(t *testing.T) { }), Overwrite: true, FolderId: 2, + UserId: 100, } err := SaveDashboard(&cmd) @@ -149,6 +169,7 @@ func TestDashboardDataAccess(t *testing.T) { }), FolderId: 0, Overwrite: true, + UserId: 100, } err = SaveDashboard(&cmd) @@ -162,6 +183,10 @@ func TestDashboardDataAccess(t *testing.T) { err = GetDashboard(&query) So(err, ShouldBeNil) So(query.Result.FolderId, ShouldEqual, 0) + So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy) + So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second)) + So(query.Result.UpdatedBy, ShouldEqual, 100) + So(query.Result.Updated.IsZero(), ShouldBeFalse) }) Convey("Should be able to delete a dashboard folder and its children", func() { diff --git a/public/app/containers/ManageDashboards/FolderPermissions.tsx b/public/app/containers/ManageDashboards/FolderPermissions.tsx index 93cfe9494c2..9c82db1c18c 100644 --- a/public/app/containers/ManageDashboards/FolderPermissions.tsx +++ b/public/app/containers/ManageDashboards/FolderPermissions.tsx @@ -26,7 +26,7 @@ export class FolderPermissions extends Component { loadStore() { const { nav, folder, view } = this.props; return folder.load(view.routeParams.get('uid') as string).then(res => { - view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {}); + view.updatePathAndQuery(`${res.url}/permissions`, {}, {}); return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions'); }); } diff --git a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx index 3630bf534ba..bed3d569bcc 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.jest.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.jest.tsx @@ -9,17 +9,14 @@ describe('FolderSettings', () => { let page; beforeAll(() => { - backendSrv.getDashboardByUid.mockReturnValue( + backendSrv.getFolderByUid.mockReturnValue( Promise.resolve({ - dashboard: { - id: 1, - title: 'Folder Name', - uid: 'uid-str', - }, - meta: { - url: '/dashboards/f/uid/folder-name', - canSave: true, - }, + id: 1, + uid: 'uid', + title: 'Folder Name', + url: '/dashboards/f/uid/folder-name', + canSave: true, + version: 1, }) ); diff --git a/public/app/containers/ManageDashboards/FolderSettings.tsx b/public/app/containers/ManageDashboards/FolderSettings.tsx index a6349764a14..586a8f05b4c 100644 --- a/public/app/containers/ManageDashboards/FolderSettings.tsx +++ b/public/app/containers/ManageDashboards/FolderSettings.tsx @@ -10,7 +10,6 @@ import appEvents from 'app/core/app_events'; @observer export class FolderSettings extends React.Component { formSnapshot: any; - dashboard: any; constructor(props) { super(props); @@ -22,9 +21,7 @@ export class FolderSettings extends React.Component { return folder.load(view.routeParams.get('uid') as string).then(res => { this.formSnapshot = getSnapshot(folder); - this.dashboard = res.dashboard; - - view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {}); + view.updatePathAndQuery(`${res.url}/settings`, {}, {}); return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); }); @@ -51,7 +48,7 @@ export class FolderSettings extends React.Component { const { nav, folder, view } = this.props; folder - .saveFolder(this.dashboard, { overwrite: false }) + .saveFolder({ overwrite: false }) .then(newUrl => { view.updatePathAndQuery(newUrl, {}, {}); @@ -61,7 +58,7 @@ export class FolderSettings extends React.Component { .then(() => { return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); }) - .catch(this.handleSaveFolderError); + .catch(this.handleSaveFolderError.bind(this)); } delete(evt) { @@ -79,7 +76,7 @@ export class FolderSettings extends React.Component { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - return this.props.folder.deleteFolder().then(() => { + return folder.deleteFolder().then(() => { appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]); view.updatePathAndQuery('dashboards', '', ''); }); @@ -91,6 +88,8 @@ export class FolderSettings extends React.Component { if (err.data && err.data.status === 'version-mismatch') { err.isHandled = true; + const { nav, folder, view } = this.props; + appEvents.emit('confirm-modal', { title: 'Conflict', text: 'Someone else has updated this folder.', @@ -98,16 +97,20 @@ export class FolderSettings extends React.Component { yesText: 'Save & Overwrite', icon: 'fa-warning', onConfirm: () => { - this.props.folder.saveFolder(this.dashboard, { overwrite: true }); + folder + .saveFolder({ overwrite: true }) + .then(newUrl => { + view.updatePathAndQuery(newUrl, {}, {}); + + appEvents.emit('dashboard-saved'); + appEvents.emit('alert-success', ['Folder saved']); + }) + .then(() => { + return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); + }); }, }); } - - if (err.data && err.data.status === 'name-exists') { - err.isHandled = true; - - appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']); - } } render() { diff --git a/public/app/core/components/Permissions/AddPermissions.jest.tsx b/public/app/core/components/Permissions/AddPermissions.jest.tsx index 48ff20a16aa..9c01eee70b1 100644 --- a/public/app/core/components/Permissions/AddPermissions.jest.tsx +++ b/public/app/core/components/Permissions/AddPermissions.jest.tsx @@ -53,7 +53,7 @@ describe('AddPermissions', () => { wrapper.find('form').simulate('submit', { preventDefault() {} }); expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); }); @@ -80,7 +80,7 @@ describe('AddPermissions', () => { wrapper.find('form').simulate('submit', { preventDefault() {} }); expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); }); diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index 4a8ab32ab7d..545119a80d7 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -78,8 +78,8 @@ export class ManageDashboardsCtrl { return; } - return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => { - this.canSave = dash.meta.canSave; + return this.backendSrv.getFolderByUid(this.folderUid).then(folder => { + this.canSave = folder.canSave; }); }); } @@ -173,48 +173,13 @@ export class ManageDashboardsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - const foldersAndDashboards = data.folders.concat(data.dashboards); - this.deleteFoldersAndDashboards(foldersAndDashboards); + this.deleteFoldersAndDashboards(data.folders, data.dashboards); }, }); } - private deleteFoldersAndDashboards(uids) { - this.backendSrv.deleteDashboards(uids).then(result => { - const folders = _.filter(result, dash => dash.meta.isFolder); - const folderCount = folders.length; - const dashboards = _.filter(result, dash => !dash.meta.isFolder); - const dashCount = dashboards.length; - - if (result.length > 0) { - let header; - let msg; - - if (folderCount > 0 && dashCount > 0) { - header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`; - msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `; - msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; - } else if (folderCount > 0) { - header = `Folder${folderCount === 1 ? '' : 's'} Deleted`; - - if (folderCount === 1) { - msg = `${folders[0].dashboard.title} has been deleted`; - } else { - msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`; - } - } else if (dashCount > 0) { - header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`; - - if (dashCount === 1) { - msg = `${dashboards[0].dashboard.title} has been deleted`; - } else { - msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`; - } - } - - appEvents.emit('alert-success', [header, msg]); - } - + private deleteFoldersAndDashboards(folderUids, dashboardUids) { + this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => { this.refreshList(); }); } diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 0a8b305ea53..8b7ca518e8b 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -221,14 +221,18 @@ export class BackendSrv { return this.get('/api/search', query); } - getDashboard(type, slug) { - return this.get('/api/dashboards/' + type + '/' + slug); + getDashboardBySlug(slug) { + return this.get(`/api/dashboards/db/${slug}`); } getDashboardByUid(uid: string) { return this.get(`/api/dashboards/uid/${uid}`); } + getFolderByUid(uid: string) { + return this.get(`/api/folders/${uid}`); + } + saveDashboard(dash, options) { options = options || {}; @@ -240,55 +244,41 @@ export class BackendSrv { }); } - createDashboardFolder(name) { - const dash = { - schemaVersion: 16, - title: name.trim(), - editable: true, - panels: [], - }; - - return this.post('/api/dashboards/db/', { - dashboard: dash, - isFolder: true, - overwrite: false, - }).then(res => { - return this.getDashboard('db', res.slug); - }); + createFolder(payload: any) { + return this.post('/api/folders', payload); } - saveFolder(dash, options) { + updateFolder(folder, options) { options = options || {}; - return this.post('/api/dashboards/db/', { - dashboard: dash, - isFolder: true, + return this.put(`/api/folders/${folder.uid}`, { + title: folder.title, + version: folder.version, overwrite: options.overwrite === true, - message: options.message || '', }); } - deleteDashboard(uid) { - let deferred = this.$q.defer(); - - this.getDashboardByUid(uid).then(fullDash => { - this.delete(`/api/dashboards/uid/${uid}`) - .then(() => { - deferred.resolve(fullDash); - }) - .catch(err => { - deferred.reject(err); - }); - }); - - return deferred.promise; + deleteFolder(uid: string, showSuccessAlert) { + return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true }); } - deleteDashboards(dashboardUids) { + deleteDashboard(uid, showSuccessAlert) { + return this.request({ + method: 'DELETE', + url: `/api/dashboards/uid/${uid}`, + showSuccessAlert: showSuccessAlert === true, + }); + } + + deleteFoldersAndDashboards(folderUids, dashboardUids) { const tasks = []; - for (let uid of dashboardUids) { - tasks.push(this.createTask(this.deleteDashboard.bind(this), true, uid)); + for (let folderUid of folderUids) { + tasks.push(this.createTask(this.deleteFolder.bind(this), true, folderUid, true)); + } + + for (let dashboardUid of dashboardUids) { + tasks.push(this.createTask(this.deleteDashboard.bind(this), true, dashboardUid, true)); } return this.executeInOrder(tasks, []); diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/dashboard/create_folder_ctrl.ts index 4b74d290e34..5c8bd276f76 100644 --- a/public/app/features/dashboard/create_folder_ctrl.ts +++ b/public/app/features/dashboard/create_folder_ctrl.ts @@ -18,9 +18,9 @@ export class CreateFolderCtrl { return; } - return this.backendSrv.createDashboardFolder(this.title).then(result => { + return this.backendSrv.createFolder({ title: this.title }).then(result => { appEvents.emit('alert-success', ['Folder Created', 'OK']); - this.$location.url(locationUtil.stripBaseFromUrl(result.meta.url)); + this.$location.url(locationUtil.stripBaseFromUrl(result.url)); }); } diff --git a/public/app/features/dashboard/folder_dashboards_ctrl.ts b/public/app/features/dashboard/folder_dashboards_ctrl.ts index 8ee942445ae..05cc420c489 100644 --- a/public/app/features/dashboard/folder_dashboards_ctrl.ts +++ b/public/app/features/dashboard/folder_dashboards_ctrl.ts @@ -14,7 +14,7 @@ export class FolderDashboardsCtrl { const loader = new FolderPageLoader(this.backendSrv); loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => { - const url = locationUtil.stripBaseFromUrl(folder.meta.url); + const url = locationUtil.stripBaseFromUrl(folder.url); if (url !== $location.path()) { $location.path(url).replace(); diff --git a/public/app/features/dashboard/folder_page_loader.ts b/public/app/features/dashboard/folder_page_loader.ts index 81d10068361..6842c61847e 100755 --- a/public/app/features/dashboard/folder_page_loader.ts +++ b/public/app/features/dashboard/folder_page_loader.ts @@ -36,16 +36,16 @@ export class FolderPageLoader { }, }; - return this.backendSrv.getDashboardByUid(uid).then(result => { - ctrl.folderId = result.dashboard.id; - const folderTitle = result.dashboard.title; - const folderUrl = result.meta.url; + return this.backendSrv.getFolderByUid(uid).then(folder => { + ctrl.folderId = folder.id; + const folderTitle = folder.title; + const folderUrl = folder.url; ctrl.navModel.main.text = folderTitle; const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards'); dashTab.url = folderUrl; - if (result.meta.canAdmin) { + if (folder.canAdmin) { const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions'); permTab.url = folderUrl + '/permissions'; @@ -55,7 +55,7 @@ export class FolderPageLoader { ctrl.navModel.main.children = [dashTab]; } - return result; + return folder; }); } } diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts index 0e5c22c4db2..cbf23e3ea4b 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -89,13 +89,13 @@ export class FolderPickerCtrl { evt.preventDefault(); } - return this.backendSrv.createDashboardFolder(this.newFolderName).then(result => { + return this.backendSrv.createFolder({ title: this.newFolderName }).then(result => { appEvents.emit('alert-success', ['Folder Created', 'OK']); this.closeCreateFolder(); this.folder = { - text: result.dashboard.title, - value: result.dashboard.id, + text: result.title, + value: result.id, }; this.onFolderChange(this.folder); }); diff --git a/public/app/features/dashboard/folder_settings_ctrl.ts b/public/app/features/dashboard/folder_settings_ctrl.ts index 004ba2efa9f..a847c29ac56 100644 --- a/public/app/features/dashboard/folder_settings_ctrl.ts +++ b/public/app/features/dashboard/folder_settings_ctrl.ts @@ -7,8 +7,7 @@ export class FolderSettingsCtrl { folderId: number; uid: string; canSave = false; - dashboard: any; - meta: any; + folder: any; title: string; hasChanged: boolean; @@ -23,10 +22,9 @@ export class FolderSettingsCtrl { $location.path(`${folder.meta.url}/settings`).replace(); } - this.dashboard = folder.dashboard; - this.meta = folder.meta; - this.canSave = folder.meta.canSave; - this.title = this.dashboard.title; + this.folder = folder; + this.canSave = this.folder.canSave; + this.title = this.folder.title; }); } } @@ -38,10 +36,10 @@ export class FolderSettingsCtrl { return; } - this.dashboard.title = this.title.trim(); + this.folder.title = this.title.trim(); return this.backendSrv - .updateDashboardFolder(this.dashboard, { overwrite: false }) + .updateFolder(this.folder) .then(result => { if (result.url !== this.$location.path()) { this.$location.url(result.url + '/settings'); @@ -54,7 +52,7 @@ export class FolderSettingsCtrl { } titleChanged() { - this.hasChanged = this.dashboard.title.toLowerCase() !== this.title.trim().toLowerCase(); + this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase(); } delete(evt) { @@ -69,8 +67,8 @@ export class FolderSettingsCtrl { icon: 'fa-trash', yesText: 'Delete', onConfirm: () => { - return this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => { - appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]); + return this.backendSrv.deleteFolder(this.uid).then(() => { + appEvents.emit('alert-success', ['Folder Deleted', `${this.folder.title} has been deleted`]); this.$location.url('dashboards'); }); }, @@ -88,15 +86,9 @@ export class FolderSettingsCtrl { yesText: 'Save & Overwrite', icon: 'fa-warning', onConfirm: () => { - this.backendSrv.updateDashboardFolder(this.dashboard, { overwrite: true }); + this.backendSrv.updateFolder(this.folder, { overwrite: true }); }, }); } - - if (err.data && err.data.status === 'name-exists') { - err.isHandled = true; - - appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']); - } } } diff --git a/public/app/features/dashboard/specs/dashboard_migration.jest.ts b/public/app/features/dashboard/specs/dashboard_migration.jest.ts index 931527ec732..07a29d58e65 100644 --- a/public/app/features/dashboard/specs/dashboard_migration.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_migration.jest.ts @@ -374,7 +374,7 @@ describe('DashboardModel', function() { it('should assign id', function() { model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])]; - model.rows[0].panels[0] = { }; + model.rows[0].panels[0] = {}; let dashboard = new DashboardModel(model); expect(dashboard.panels[0].id).toBe(1); diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index 2c7698db08e..242d2e7da3e 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -18,7 +18,7 @@ export class SoloPanelCtrl { // if no uid, redirect to new route based on slug if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { - backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => { + backendSrv.getDashboardBySlug($routeParams.slug).then(res => { if (res) { const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); $location.path(url).replace(); diff --git a/public/app/routes/dashboard_loaders.ts b/public/app/routes/dashboard_loaders.ts index d84f03ed38f..9224ec33bcc 100644 --- a/public/app/routes/dashboard_loaders.ts +++ b/public/app/routes/dashboard_loaders.ts @@ -21,7 +21,7 @@ export class LoadDashboardCtrl { // if no uid, redirect to new route based on slug if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { - backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => { + backendSrv.getDashboardBySlug($routeParams.slug).then(res => { if (res) { $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace(); } diff --git a/public/app/stores/FolderStore/FolderStore.ts b/public/app/stores/FolderStore/FolderStore.ts index 46010475622..cbdb74dd3ab 100644 --- a/public/app/stores/FolderStore/FolderStore.ts +++ b/public/app/stores/FolderStore/FolderStore.ts @@ -2,11 +2,12 @@ import { types, getEnv, flow } from 'mobx-state-tree'; export const Folder = types.model('Folder', { id: types.identifier(types.number), + uid: types.string, title: types.string, url: types.string, canSave: types.boolean, - uid: types.string, hasChanged: types.boolean, + version: types.number, }); export const FolderStore = types @@ -21,15 +22,15 @@ export const FolderStore = types } const backendSrv = getEnv(self).backendSrv; - const res = yield backendSrv.getDashboardByUid(uid); - + const res = yield backendSrv.getFolderByUid(uid); self.folder = Folder.create({ - id: res.dashboard.id, - title: res.dashboard.title, - url: res.meta.url, - uid: res.dashboard.uid, - canSave: res.meta.canSave, + id: res.id, + uid: res.uid, + title: res.title, + url: res.url, + canSave: res.canSave, hasChanged: false, + version: res.version, }); return res; @@ -40,12 +41,13 @@ export const FolderStore = types self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0; }, - saveFolder: flow(function* saveFolder(dashboard: any, options: any) { + saveFolder: flow(function* saveFolder(options: any) { const backendSrv = getEnv(self).backendSrv; - dashboard.title = self.folder.title.trim(); + self.folder.title = self.folder.title.trim(); - const res = yield backendSrv.saveFolder(dashboard, options); + const res = yield backendSrv.updateFolder(self.folder, options); self.folder.url = res.url; + self.folder.version = res.version; return `${self.folder.url}/settings`; }), @@ -53,6 +55,6 @@ export const FolderStore = types deleteFolder: flow(function* deleteFolder() { const backendSrv = getEnv(self).backendSrv; - return backendSrv.deleteDashboard(self.folder.uid); + return backendSrv.deleteFolder(self.folder.uid); }), })); diff --git a/public/app/stores/PermissionsStore/PermissionsStore.jest.ts b/public/app/stores/PermissionsStore/PermissionsStore.jest.ts index 97a9906d0e5..f7332516bef 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.jest.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.jest.ts @@ -44,7 +44,7 @@ describe('PermissionsStore', () => { expect(store.items[0].permission).toBe(2); expect(store.items[0].permissionName).toBe('Edit'); expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); it('should save removed permissions automatically', () => { @@ -54,7 +54,7 @@ describe('PermissionsStore', () => { expect(store.items.length).toBe(2); expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl'); + expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); }); describe('when duplicate team permissions are added', () => { diff --git a/public/app/stores/PermissionsStore/PermissionsStore.ts b/public/app/stores/PermissionsStore/PermissionsStore.ts index a7c90d13da0..88b26a2886f 100644 --- a/public/app/stores/PermissionsStore/PermissionsStore.ts +++ b/public/app/stores/PermissionsStore/PermissionsStore.ts @@ -110,7 +110,7 @@ export const PermissionsStore = types self.dashboardId = dashboardId; self.items.clear(); - const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`); + const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/permissions`); const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot); self.items = items; self.originalItems = items; @@ -210,7 +210,7 @@ const updateItems = self => { let res; try { - res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/acl`, { + res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, { items: updated, }); } catch (error) { diff --git a/public/sass/base/_fonts.scss b/public/sass/base/_fonts.scss index aab86329612..558381e169a 100644 --- a/public/sass/base/_fonts.scss +++ b/public/sass/base/_fonts.scss @@ -1,290 +1,254 @@ -@import "font_awesome"; -@import "grafana_icons"; +@import 'font_awesome'; +@import 'grafana_icons'; /* cyrillic-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); + src: local('Roboto'), local('Roboto-Regular'), + url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F; } /* cyrillic */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); + src: local('Roboto'), local('Roboto-Regular'), + url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116; } /* greek-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); + src: local('Roboto'), local('Roboto-Regular'), + url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); unicode-range: U+1f00-1fff; } /* greek */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); + src: local('Roboto'), local('Roboto-Regular'), + url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); unicode-range: U+0370-03ff; } /* vietnamese */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); + src: local('Roboto'), local('Roboto-Regular'), + url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab; } /* latin-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); - unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, - U+A720-A7FF; + src: local('Roboto'), local('Roboto-Regular'), + url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF; } /* latin */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 400; - src: local("Roboto"), local("Roboto-Regular"), - url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format("woff2"); - unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, - U+2000-206f, U+2074, U+20ac, U+2212, U+2215; + src: local('Roboto'), local('Roboto-Regular'), url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format('woff2'); + unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215; } /* cyrillic-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2) - format("woff2"); + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F; } /* cyrillic */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2) - format("woff2"); + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116; } /* greek-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2) - format("woff2"); + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); unicode-range: U+1f00-1fff; } /* greek */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2) - format("woff2"); + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); unicode-range: U+0370-03ff; } /* vietnamese */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2) - format("woff2"); + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab; } /* latin-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2) - format("woff2"); - unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, - U+A720-A7FF; + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF; } /* latin */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: normal; font-weight: 500; - src: local("Roboto Medium"), local("Roboto-Medium"), - url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2) - format("woff2"); - unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, - U+2000-206f, U+2074, U+20ac, U+2212, U+2215; + src: local('Roboto Medium'), local('Roboto-Medium'), + url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2) format('woff2'); + unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215; } /* cyrillic-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2) - format("woff2"); + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F; } /* cyrillic */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2) - format("woff2"); + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116; } /* greek-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2) - format("woff2"); + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); unicode-range: U+1f00-1fff; } /* greek */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2) - format("woff2"); + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); unicode-range: U+0370-03ff; } /* vietnamese */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2) - format("woff2"); + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab; } /* latin-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2) - format("woff2"); - unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, - U+A720-A7FF; + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2) format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF; } /* latin */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 400; - src: local("Roboto Italic"), local("Roboto-Italic"), - url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2) - format("woff2"); - unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, - U+2000-206f, U+2074, U+20ac, U+2212, U+2215; + src: local('Roboto Italic'), local('Roboto-Italic'), + url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2) format('woff2'); + unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215; } /* cyrillic-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2) - format("woff2"); + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2) format('woff2'); unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F; } /* cyrillic */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2) - format("woff2"); + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2) format('woff2'); unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116; } /* greek-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2) - format("woff2"); + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2) format('woff2'); unicode-range: U+1f00-1fff; } /* greek */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2) - format("woff2"); + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2) format('woff2'); unicode-range: U+0370-03ff; } /* vietnamese */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2) - format("woff2"); + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2) format('woff2'); unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab; } /* latin-ext */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2) - format("woff2"); - unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, - U+A720-A7FF; + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2) format('woff2'); + unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF; } /* latin */ @font-face { - font-family: "Roboto"; + font-family: 'Roboto'; font-style: italic; font-weight: 500; - src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"), - url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2) - format("woff2"); - unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, - U+2000-206f, U+2074, U+20ac, U+2212, U+2215; + src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), + url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2) format('woff2'); + unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215; } diff --git a/public/test/mocks/common.ts b/public/test/mocks/common.ts index 3f80227435d..1531f2ed176 100644 --- a/public/test/mocks/common.ts +++ b/public/test/mocks/common.ts @@ -2,6 +2,7 @@ export const backendSrv = { get: jest.fn(), getDashboard: jest.fn(), getDashboardByUid: jest.fn(), + getFolderByUid: jest.fn(), post: jest.fn(), }; diff --git a/tests/api/folder.test.ts b/tests/api/folder.test.ts new file mode 100644 index 00000000000..0d40972aa6d --- /dev/null +++ b/tests/api/folder.test.ts @@ -0,0 +1,78 @@ +import client from './client'; +import * as setup from './setup'; + +describe('/api/folders', () => { + let state: any = {}; + + beforeAll(async () => { + state = await setup.ensureState({ + orgName: 'api-test-org', + users: [ + { user: setup.admin, role: 'Admin' }, + { user: setup.editor, role: 'Editor' }, + { user: setup.viewer, role: 'Viewer' }, + ], + admin: setup.admin, + folders: [ + { + title: 'Folder 1', + uid: 'f-01', + }, + { + title: 'Folder 2', + uid: 'f-02', + }, + { + title: 'Folder 3', + uid: 'f-03', + }, + ], + }); + }); + + describe('With admin user', () => { + it('can delete folder', async () => { + let rsp = await client.callAs(setup.admin).delete(`/api/folders/f-01`); + expect(rsp.data.title).toBe('Folder 1'); + }); + + it('can update folder', async () => { + let rsp = await client.callAs(setup.admin).put(`/api/folders/f-02`, { + uid: 'f-02', + title: 'Folder 2 upd', + overwrite: true, + }); + expect(rsp.data.title).toBe('Folder 2 upd'); + }); + + it('can update folder uid', async () => { + let rsp = await client.callAs(setup.admin).put(`/api/folders/f-03`, { + uid: 'f-03-upd', + title: 'Folder 3 upd', + overwrite: true, + }); + expect(rsp.data.uid).toBe('f-03-upd'); + expect(rsp.data.title).toBe('Folder 3 upd'); + }); + }); + + describe('With viewer user', () => { + it('Cannot delete folder', async () => { + let rsp = await setup.expectError(() => { + return client.callAs(setup.viewer).delete(`/api/folders/f-02`); + }); + expect(rsp.response.status).toBe(403); + }); + + it('Cannot update folder', async () => { + let rsp = await setup.expectError(() => { + return client.callAs(setup.viewer).put(`/api/folders/f-02`, { + uid: 'f-02', + title: 'Folder 2 upd', + overwrite: true, + }); + }); + expect(rsp.response.status).toBe(403); + }); + }); +}); diff --git a/tests/api/setup.ts b/tests/api/setup.ts index 0566729999c..94b0b20b8b8 100644 --- a/tests/api/setup.ts +++ b/tests/api/setup.ts @@ -90,6 +90,18 @@ export async function createDashboard(user, dashboard) { return dashboard; } +export async function createFolder(user, folder) { + const rsp = await client.callAs(user).post(`/api/folders`, { + uid: folder.uid, + title: folder.title, + overwrite: true, + }); + folder.id = rsp.id; + folder.url = rsp.url; + + return folder; +} + export async function ensureState(state) { const org = await getOrg(state.orgName); @@ -99,9 +111,13 @@ export async function ensureState(state) { await setUsingOrg(user, org); } - for (let dashboard of state.dashboards) { + for (let dashboard of state.dashboards || []) { await createDashboard(state.admin, dashboard); } + for (let folder of state.folders || []) { + await createFolder(state.admin, folder); + } + return state; }