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:
Jesús Espino 2022-04-08 19:31:28 +02:00 committed by GitHub
parent 30e6bc477d
commit f3267e2458
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 572 additions and 18 deletions

View file

@ -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
//

View file

@ -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
}

View file

@ -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 != "" {

View file

@ -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) {

View file

@ -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

View file

@ -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()

View file

@ -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 = ""

View file

@ -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,

View file

@ -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)
})

View file

@ -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()

View file

@ -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)
}

View file

@ -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()

View file

@ -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").

View file

@ -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)

View file

@ -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

View file

@ -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,
}

View file

@ -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,

View file

@ -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(),
})