Fixing delete/undelete boards (#2571)
* Fixing delete/undelete boards * Adding permissions checks for undelete board and undelete block * Fixing server-lint * Handling permissions for deleted boards * Fixing linter errors * Fixing tests * Update server/services/store/sqlstore/board.go Co-authored-by: Doug Lauder <wiggin77@warpmail.net> * Fixing error message Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Doug Lauder <wiggin77@warpmail.net>
This commit is contained in:
parent
30e6bc477d
commit
f3267e2458
18 changed files with 572 additions and 18 deletions
|
@ -81,6 +81,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
|||
apiv1.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
|
||||
apiv1.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE")
|
||||
apiv1.HandleFunc("/boards/{boardID}/duplicate", a.sessionRequired(a.handleDuplicateBoard)).Methods("POST")
|
||||
apiv1.HandleFunc("/boards/{boardID}/undelete", a.sessionRequired(a.handleUndeleteBoard)).Methods("POST")
|
||||
apiv1.HandleFunc("/boards/{boardID}/blocks", a.attachSession(a.handleGetBlocks, false)).Methods("GET")
|
||||
apiv1.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePostBlocks)).Methods("POST")
|
||||
apiv1.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePatchBlocks)).Methods("PATCH")
|
||||
|
@ -1103,6 +1104,58 @@ func (a *API) handleUndeleteBlock(w http.ResponseWriter, r *http.Request) {
|
|||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleUndeleteBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /api/v1/boards/{boardID}/undelete undeleteBoard
|
||||
//
|
||||
// Undeletes a board
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: boardID
|
||||
// in: path
|
||||
// description: ID of board to undelete
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
userID := session.UserID
|
||||
|
||||
vars := mux.Vars(r)
|
||||
boardID := vars["boardID"]
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "undeleteBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("boardID", boardID)
|
||||
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionDeleteBoard) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to undelete board"})
|
||||
return
|
||||
}
|
||||
|
||||
err := a.app.UndeleteBoard(boardID, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("UNDELETE Board", mlog.String("boardID", boardID))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation PATCH /api/v1/boards/{boardID}/blocks/{blockID} patchBlock
|
||||
//
|
||||
|
|
|
@ -85,7 +85,7 @@ func (a *App) getBoardForBlock(blockID string) (*model.Board, error) {
|
|||
}
|
||||
|
||||
func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) {
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
opts := model.QueryBoardHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: latest,
|
||||
}
|
||||
|
@ -368,3 +368,37 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
|
|||
func (a *App) SearchBoardsForUserAndTeam(term, userID, teamID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUserAndTeam(term, userID, teamID)
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
// undeleting non-existing board not considered an error
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.store.UndeleteBoard(boardID, modifiedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if board == nil {
|
||||
a.logger.Error("Error loading the board after undelete, not propagating through websockets or notifications")
|
||||
return nil
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -472,6 +472,16 @@ func (c *Client) DeleteBoard(boardID string) (bool, *Response) {
|
|||
return true, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) UndeleteBoard(boardID string) (bool, *Response) {
|
||||
r, err := c.DoAPIPost(c.GetBoardRoute(boardID)+"/undelete", "")
|
||||
if err != nil {
|
||||
return false, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return true, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) GetBoard(boardID, readToken string) (*model.Board, *Response) {
|
||||
url := c.GetBoardRoute(boardID)
|
||||
if readToken != "" {
|
||||
|
|
|
@ -375,6 +375,22 @@ func TestUndeleteBlock(t *testing.T) {
|
|||
require.NoError(t, resp.Error)
|
||||
require.Len(t, blocks, initialCount+1)
|
||||
})
|
||||
|
||||
t.Run("Try to undelete a block without permissions", func(t *testing.T) {
|
||||
// this avoids triggering uniqueness constraint of
|
||||
// id,insert_at on block history
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
_, resp := th.Client.DeleteBlock(board.ID, blockID)
|
||||
require.NoError(t, resp.Error)
|
||||
|
||||
_, resp = th.Client2.UndeleteBlock(board.ID, blockID)
|
||||
th.CheckForbidden(resp)
|
||||
|
||||
blocks, resp := th.Client.GetBlocksForBoard(board.ID)
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, blocks, initialCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSubtree(t *testing.T) {
|
||||
|
|
|
@ -853,6 +853,128 @@ func TestDeleteBoard(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestUndeleteBoard(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
|
||||
t.Run("a non authenticated user should be rejected", func(t *testing.T) {
|
||||
th := SetupTestHelper(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.Logout(th.Client)
|
||||
|
||||
newBoard := &model.Board{
|
||||
Title: "title",
|
||||
Type: model.BoardTypeOpen,
|
||||
TeamID: teamID,
|
||||
}
|
||||
board, err := th.Server.App().CreateBoard(newBoard, "user-id", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = th.Server.App().DeleteBoard(newBoard.ID, "user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
success, resp := th.Client.UndeleteBoard(board.ID)
|
||||
th.CheckUnauthorized(resp)
|
||||
require.False(t, success)
|
||||
|
||||
dbBoard, err := th.Server.App().GetBoard(board.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, dbBoard)
|
||||
})
|
||||
|
||||
t.Run("a user without membership should be rejected", func(t *testing.T) {
|
||||
th := SetupTestHelper(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
newBoard := &model.Board{
|
||||
Title: "title",
|
||||
Type: model.BoardTypeOpen,
|
||||
TeamID: teamID,
|
||||
}
|
||||
board, err := th.Server.App().CreateBoard(newBoard, "some-user-id", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = th.Server.App().DeleteBoard(newBoard.ID, "some-user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
success, resp := th.Client.UndeleteBoard(board.ID)
|
||||
th.CheckForbidden(resp)
|
||||
require.False(t, success)
|
||||
|
||||
dbBoard, err := th.Server.App().GetBoard(board.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, dbBoard)
|
||||
})
|
||||
|
||||
t.Run("a user with membership but without permissions should be rejected", func(t *testing.T) {
|
||||
th := SetupTestHelper(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
newBoard := &model.Board{
|
||||
Title: "title",
|
||||
Type: model.BoardTypeOpen,
|
||||
TeamID: teamID,
|
||||
}
|
||||
board, err := th.Server.App().CreateBoard(newBoard, "some-user-id", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
newUser2Member := &model.BoardMember{
|
||||
UserID: "user-id",
|
||||
BoardID: board.ID,
|
||||
SchemeEditor: true,
|
||||
}
|
||||
_, err = th.Server.App().AddMemberToBoard(newUser2Member)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = th.Server.App().DeleteBoard(newBoard.ID, "some-user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
success, resp := th.Client.UndeleteBoard(board.ID)
|
||||
th.CheckForbidden(resp)
|
||||
require.False(t, success)
|
||||
|
||||
dbBoard, err := th.Server.App().GetBoard(board.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, dbBoard)
|
||||
})
|
||||
|
||||
t.Run("non existing board", func(t *testing.T) {
|
||||
th := SetupTestHelper(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
success, resp := th.Client.UndeleteBoard("non-existing-board")
|
||||
th.CheckForbidden(resp)
|
||||
require.False(t, success)
|
||||
})
|
||||
|
||||
t.Run("an existing deleted board should be correctly undeleted", func(t *testing.T) {
|
||||
th := SetupTestHelper(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
newBoard := &model.Board{
|
||||
Title: "title",
|
||||
Type: model.BoardTypeOpen,
|
||||
TeamID: teamID,
|
||||
}
|
||||
board, err := th.Server.App().CreateBoard(newBoard, th.GetUser1().ID, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = th.Server.App().DeleteBoard(newBoard.ID, "user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
success, resp := th.Client.UndeleteBoard(board.ID)
|
||||
th.CheckOK(resp)
|
||||
require.True(t, success)
|
||||
|
||||
dbBoard, err := th.Server.App().GetBoard(board.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dbBoard)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMembersForBoard(t *testing.T) {
|
||||
teamID := testTeamID
|
||||
|
||||
|
|
|
@ -846,6 +846,57 @@ func TestPermissionsUndeleteBoardBlock(t *testing.T) {
|
|||
runTestCases(t, ttCases, testData, clients)
|
||||
}
|
||||
|
||||
func TestPermissionsUndeleteBoard(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
testData := setupData(t, th)
|
||||
clients := setupClients(th)
|
||||
|
||||
err := th.Server.App().DeleteBoard(testData.publicBoard.ID, userAdmin)
|
||||
require.NoError(t, err)
|
||||
err = th.Server.App().DeleteBoard(testData.privateBoard.ID, userAdmin)
|
||||
require.NoError(t, err)
|
||||
err = th.Server.App().DeleteBoard(testData.publicTemplate.ID, userAdmin)
|
||||
require.NoError(t, err)
|
||||
err = th.Server.App().DeleteBoard(testData.privateTemplate.ID, userAdmin)
|
||||
require.NoError(t, err)
|
||||
|
||||
ttCases := []TestCase{
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_BOARD_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0},
|
||||
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_BOARD_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0},
|
||||
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PRIVATE_TEMPLATE_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0},
|
||||
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userAnon, http.StatusUnauthorized, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userNoTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userTeamMember, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userViewer, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userCommenter, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userEditor, http.StatusForbidden, 0},
|
||||
{"/boards/{PUBLIC_TEMPLATE_ID}/undelete", methodPost, "", userAdmin, http.StatusOK, 0},
|
||||
}
|
||||
runTestCases(t, ttCases, testData, clients)
|
||||
}
|
||||
|
||||
func TestPermissionsDuplicateBoardBlock(t *testing.T) {
|
||||
th := SetupTestHelperPluginMode(t)
|
||||
defer th.TearDown()
|
||||
|
|
|
@ -187,6 +187,14 @@ type QueryBlockHistoryOptions struct {
|
|||
Descending bool // if true then the records are sorted by insert_at in descending order
|
||||
}
|
||||
|
||||
// QueryBoardHistoryOptions are query options that can be passed to GetBoardHistory.
|
||||
type QueryBoardHistoryOptions struct {
|
||||
BeforeUpdateAt int64 // if non-zero then filter for records with update_at less than BeforeUpdateAt
|
||||
AfterUpdateAt int64 // if non-zero then filter for records with update_at greater than AfterUpdateAt
|
||||
Limit uint64 // if non-zero then limit the number of returned records
|
||||
Descending bool // if true then the records are sorted by insert_at in descending order
|
||||
}
|
||||
|
||||
func StampModificationMetadata(userID string, blocks []Block, auditRec *audit.Record) {
|
||||
if userID == SingleUser {
|
||||
userID = ""
|
||||
|
|
|
@ -44,9 +44,16 @@ func (s *Service) HasPermissionToBoard(userID, boardID string, permission *mmMod
|
|||
|
||||
board, err := s.store.GetBoard(boardID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
var boards []*model.Board
|
||||
boards, err = s.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(boards) == 0 {
|
||||
return false
|
||||
}
|
||||
board = boards[0]
|
||||
} else if err != nil {
|
||||
s.api.LogError("error getting board",
|
||||
"boardID", boardID,
|
||||
"userID", userID,
|
||||
|
|
|
@ -94,6 +94,11 @@ func TestHasPermissionToBoard(t *testing.T) {
|
|||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
th.store.EXPECT().
|
||||
GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}).
|
||||
Return(nil, sql.ErrNoRows).
|
||||
Times(1)
|
||||
|
||||
hasPermission := th.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards)
|
||||
assert.False(t, hasPermission)
|
||||
})
|
||||
|
|
|
@ -49,6 +49,21 @@ func (mr *MockStoreMockRecorder) GetBoard(arg0 interface{}) *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoard", reflect.TypeOf((*MockStore)(nil).GetBoard), arg0)
|
||||
}
|
||||
|
||||
// GetBoardHistory mocks base method.
|
||||
func (m *MockStore) GetBoardHistory(arg0 string, arg1 model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetBoardHistory", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetBoardHistory indicates an expected call of GetBoardHistory.
|
||||
func (mr *MockStoreMockRecorder) GetBoardHistory(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardHistory", reflect.TypeOf((*MockStore)(nil).GetBoardHistory), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetMemberForBoard mocks base method.
|
||||
func (m *MockStore) GetMemberForBoard(arg0, arg1 string) (*model.BoardMember, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
@ -18,4 +18,5 @@ type PermissionsService interface {
|
|||
type Store interface {
|
||||
GetBoard(boardID string) (*model.Board, error)
|
||||
GetMemberForBoard(boardID, userID string) (*model.BoardMember, error)
|
||||
GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error)
|
||||
}
|
||||
|
|
|
@ -522,7 +522,7 @@ func (mr *MockStoreMockRecorder) GetBoardAndCardByID(arg0 interface{}) *gomock.C
|
|||
}
|
||||
|
||||
// GetBoardHistory mocks base method.
|
||||
func (m *MockStore) GetBoardHistory(arg0 string, arg1 model.QueryBlockHistoryOptions) ([]*model.Board, error) {
|
||||
func (m *MockStore) GetBoardHistory(arg0 string, arg1 model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetBoardHistory", arg0, arg1)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
|
@ -1215,6 +1215,20 @@ func (mr *MockStoreMockRecorder) UndeleteBlock(arg0, arg1 interface{}) *gomock.C
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UndeleteBlock", reflect.TypeOf((*MockStore)(nil).UndeleteBlock), arg0, arg1)
|
||||
}
|
||||
|
||||
// UndeleteBoard mocks base method.
|
||||
func (m *MockStore) UndeleteBoard(arg0, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UndeleteBoard", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UndeleteBoard indicates an expected call of UndeleteBoard.
|
||||
func (mr *MockStoreMockRecorder) UndeleteBoard(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UndeleteBoard", reflect.TypeOf((*MockStore)(nil).UndeleteBoard), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateCategory mocks base method.
|
||||
func (m *MockStore) UpdateCategory(arg0 model.Category) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
@ -27,8 +27,8 @@ func boardFields(prefix string) []string {
|
|||
fields := []string{
|
||||
"id",
|
||||
"team_id",
|
||||
"channel_id",
|
||||
"created_by",
|
||||
"COALESCE(channel_id, '')",
|
||||
"COALESCE(created_by, '')",
|
||||
"modified_by",
|
||||
"type",
|
||||
"title",
|
||||
|
@ -657,7 +657,7 @@ func (s *SQLStore) searchBoardsForUserAndTeam(db sq.BaseRunner, term, userID, te
|
|||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBlockHistoryOptions) ([]*model.Board, error) {
|
||||
func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
var order string
|
||||
if opts.Descending {
|
||||
order = " DESC "
|
||||
|
@ -691,6 +691,98 @@ func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.
|
|||
return s.boardsFromRows(rows)
|
||||
}
|
||||
|
||||
func (s *SQLStore) undeleteBoard(db sq.BaseRunner, boardID string, modifiedBy string) error {
|
||||
boards, err := s.getBoardHistory(db, boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boards) == 0 {
|
||||
s.logger.Warn("undeleteBlock board not found", mlog.String("board_id", boardID))
|
||||
return nil // undeleting non-existing board is not considered an error (for now)
|
||||
}
|
||||
board := boards[0]
|
||||
|
||||
if board.DeleteAt == 0 {
|
||||
s.logger.Warn("undeleteBlock board not deleted", mlog.String("board_id", board.ID))
|
||||
return nil // undeleting not deleted board is not considered an error (for now)
|
||||
}
|
||||
|
||||
propertiesJSON, err := json.Marshal(board.Properties)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cardPropertiesJSON, err := json.Marshal(board.CardProperties)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
columnCalculationsJSON, err := json.Marshal(board.ColumnCalculations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := utils.GetMillis()
|
||||
columns := []string{
|
||||
"id",
|
||||
"team_id",
|
||||
"channel_id",
|
||||
"created_by",
|
||||
"modified_by",
|
||||
"type",
|
||||
"title",
|
||||
"description",
|
||||
"icon",
|
||||
"show_description",
|
||||
"is_template",
|
||||
"template_version",
|
||||
"properties",
|
||||
"card_properties",
|
||||
"column_calculations",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
}
|
||||
|
||||
values := []interface{}{
|
||||
board.ID,
|
||||
board.TeamID,
|
||||
"",
|
||||
board.CreatedBy,
|
||||
modifiedBy,
|
||||
board.Type,
|
||||
board.Title,
|
||||
board.Description,
|
||||
board.Icon,
|
||||
board.ShowDescription,
|
||||
board.IsTemplate,
|
||||
board.TemplateVersion,
|
||||
propertiesJSON,
|
||||
cardPropertiesJSON,
|
||||
columnCalculationsJSON,
|
||||
board.CreateAt,
|
||||
now,
|
||||
0,
|
||||
}
|
||||
insertHistoryQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "boards_history").
|
||||
Columns(columns...).
|
||||
Values(values...)
|
||||
insertQuery := s.getQueryBuilder(db).Insert(s.tablePrefix + "boards").
|
||||
Columns(columns...).
|
||||
Values(values...)
|
||||
|
||||
if _, err := insertHistoryQuery.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := insertQuery.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getBoardMemberHistory(db sq.BaseRunner, boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select("board_id", "user_id", "action", "insert_at").
|
||||
|
|
|
@ -315,7 +315,7 @@ func (s *SQLStore) GetBoardAndCardByID(blockID string) (*model.Board, *model.Blo
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetBoardHistory(boardID string, opts model.QueryBlockHistoryOptions) ([]*model.Board, error) {
|
||||
func (s *SQLStore) GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) {
|
||||
return s.getBoardHistory(s.db, boardID, opts)
|
||||
|
||||
}
|
||||
|
@ -692,6 +692,30 @@ func (s *SQLStore) UndeleteBlock(blockID string, modifiedBy string) error {
|
|||
|
||||
}
|
||||
|
||||
func (s *SQLStore) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
if s.dbType == model.SqliteDBType {
|
||||
return s.undeleteBoard(s.db, boardID, modifiedBy)
|
||||
}
|
||||
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
err := s.undeleteBoard(tx, boardID, modifiedBy)
|
||||
if err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "UndeleteBoard"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateCategory(category model.Category) error {
|
||||
return s.updateCategory(s.db, category)
|
||||
|
||||
|
|
|
@ -27,13 +27,15 @@ type Store interface {
|
|||
InsertBlocks(blocks []model.Block, userID string) error
|
||||
// @withTransaction
|
||||
UndeleteBlock(blockID string, modifiedBy string) error
|
||||
// @withTransaction
|
||||
UndeleteBoard(boardID string, modifiedBy string) error
|
||||
GetBlockCountsByType() (map[string]int64, error)
|
||||
GetBlock(blockID string) (*model.Block, error)
|
||||
// @withTransaction
|
||||
PatchBlock(blockID string, blockPatch *model.BlockPatch, userID string) error
|
||||
GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error)
|
||||
GetBlockHistoryDescendants(boardID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error)
|
||||
GetBoardHistory(boardID string, opts model.QueryBlockHistoryOptions) ([]*model.Board, error)
|
||||
GetBoardHistory(boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error)
|
||||
GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error)
|
||||
GetBoardAndCard(block *model.Block) (board *model.Board, card *model.Block, err error)
|
||||
// @withTransaction
|
||||
|
|
|
@ -38,6 +38,11 @@ func StoreTestBoardStore(t *testing.T, setup func(t *testing.T) (store.Store, fu
|
|||
defer tearDown()
|
||||
testDeleteBoard(t, store)
|
||||
})
|
||||
t.Run("UndeleteBoard", func(t *testing.T) {
|
||||
store, tearDown := setup(t)
|
||||
defer tearDown()
|
||||
testUndeleteBoard(t, store)
|
||||
})
|
||||
t.Run("InsertBoardWithAdmin", func(t *testing.T) {
|
||||
store, tearDown := setup(t)
|
||||
defer tearDown()
|
||||
|
@ -803,6 +808,93 @@ func testSearchBoardsForUserAndTeam(t *testing.T, store store.Store) {
|
|||
}
|
||||
}
|
||||
|
||||
func testUndeleteBoard(t *testing.T, store store.Store) {
|
||||
userID := testUserID
|
||||
|
||||
t.Run("existing id", func(t *testing.T) {
|
||||
boardID := utils.NewID(utils.IDTypeBoard)
|
||||
|
||||
board := &model.Board{
|
||||
ID: boardID,
|
||||
TeamID: testTeamID,
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
newBoard, err := store.InsertBoard(board, userID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBoard)
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = store.DeleteBoard(boardID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
board, err = store.GetBoard(boardID)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, board)
|
||||
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = store.UndeleteBoard(boardID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
board, err = store.GetBoard(boardID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, board)
|
||||
})
|
||||
|
||||
t.Run("existing id multiple times", func(t *testing.T) {
|
||||
boardID := utils.NewID(utils.IDTypeBoard)
|
||||
|
||||
board := &model.Board{
|
||||
ID: boardID,
|
||||
TeamID: testTeamID,
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
newBoard, err := store.InsertBoard(board, userID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newBoard)
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = store.DeleteBoard(boardID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
board, err = store.GetBoard(boardID)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, board)
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = store.UndeleteBoard(boardID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
board, err = store.GetBoard(boardID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, board)
|
||||
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err = store.UndeleteBoard(boardID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
board, err = store.GetBoard(boardID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, board)
|
||||
})
|
||||
|
||||
t.Run("from not existing id", func(t *testing.T) {
|
||||
// Wait for not colliding the ID+insert_at key
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
err := store.UndeleteBoard("not-exists", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, err := store.GetBoard("not-exists")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, block)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetBoardHistory(t *testing.T, store store.Store) {
|
||||
userID := testUserID
|
||||
|
||||
|
@ -819,7 +911,7 @@ func testGetBoardHistory(t *testing.T, store store.Store) {
|
|||
rBoard1, err := store.InsertBoard(board, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
opts := model.QueryBoardHistoryOptions{
|
||||
Limit: 0,
|
||||
Descending: false,
|
||||
}
|
||||
|
@ -867,7 +959,7 @@ func testGetBoardHistory(t *testing.T, store store.Store) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// Updated history
|
||||
opts = model.QueryBlockHistoryOptions{
|
||||
opts = model.QueryBoardHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: true,
|
||||
}
|
||||
|
@ -883,7 +975,7 @@ func testGetBoardHistory(t *testing.T, store store.Store) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// Updated history after delete
|
||||
opts = model.QueryBlockHistoryOptions{
|
||||
opts = model.QueryBoardHistoryOptions{
|
||||
Limit: 0,
|
||||
Descending: true,
|
||||
}
|
||||
|
@ -897,7 +989,7 @@ func testGetBoardHistory(t *testing.T, store store.Store) {
|
|||
})
|
||||
|
||||
t.Run("testGetBoardHistory: nonexisting board", func(t *testing.T) {
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
opts := model.QueryBoardHistoryOptions{
|
||||
Limit: 0,
|
||||
Descending: false,
|
||||
}
|
||||
|
|
|
@ -171,7 +171,7 @@ class Mutator {
|
|||
await octoClient.deleteBlock(block.boardId, block.id)
|
||||
},
|
||||
async () => {
|
||||
await octoClient.undeleteBlock(block.id)
|
||||
await octoClient.undeleteBlock(block.boardId, block.id)
|
||||
await afterUndo?.()
|
||||
},
|
||||
actualDescription,
|
||||
|
@ -221,7 +221,7 @@ class Mutator {
|
|||
},
|
||||
async () => {
|
||||
await beforeUndo?.(board)
|
||||
await octoClient.createBoard(board)
|
||||
await octoClient.undeleteBoard(board.id)
|
||||
},
|
||||
description,
|
||||
this.undoGroupId,
|
||||
|
|
|
@ -326,9 +326,17 @@ class OctoClient {
|
|||
})
|
||||
}
|
||||
|
||||
async undeleteBlock(blockId: string): Promise<Response> {
|
||||
async undeleteBlock(boardId: string, blockId: string): Promise<Response> {
|
||||
Utils.log(`undeleteBlock: ${blockId}`)
|
||||
return fetch(this.getBaseURL() + this.teamPath() + `/blocks/${encodeURIComponent(blockId)}/undelete`, {
|
||||
return fetch(`${this.getBaseURL()}/api/v1/boards/${encodeURIComponent(boardId)}/blocks/${encodeURIComponent(blockId)}/undelete`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
})
|
||||
}
|
||||
|
||||
async undeleteBoard(boardId: string): Promise<Response> {
|
||||
Utils.log(`undeleteBoard: ${boardId}`)
|
||||
return fetch(`${this.getBaseURL()}/api/v1/boards/${boardId}/undelete`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue