mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-29 02:28:16 +08:00
Data spillage deletion summary (#36018)
* Report POC * Including more error logs * Added localisationj for each reviewer * Optimisations * Minor tweaks * restored go module files * lint fixes * Added back transslations * Added translations * linter and test fixes * restored go module files * e2e lint fix * lint fixes * AI fixes * fixed typo * fixed nil pointer error * Added more tests * Publish report even if deletion fails * Fixed the e2e test * Distinguished between no data and deleted data * lint fixes * fixed tests * e2e test fix * Updated test to also upload actual file * Removed file name tracking * Text updates * fixed e2e test * lint fix
This commit is contained in:
@@ -89,8 +89,8 @@ export default class ChannelsSidebarRight {
|
||||
await expect(this.container).not.toBeVisible();
|
||||
}
|
||||
|
||||
async toContainText(text: string) {
|
||||
await expect(this.container).toContainText(text);
|
||||
async toContainText(text: string, timeout?: number) {
|
||||
await expect(this.container).toContainText(text, {timeout});
|
||||
}
|
||||
|
||||
async verifyCurrentVersionPostMessage(postID: string | null, postMessageContent: string) {
|
||||
|
||||
@@ -299,4 +299,8 @@ export default class ChannelsPage {
|
||||
|
||||
return await this.scheduleMessageModal.scheduleMessage(dayFromToday, timeOptionIndex);
|
||||
}
|
||||
|
||||
async getFlaggedPostViewDetailButton(flaggedPostId: string) {
|
||||
return this.page.getByTestId(`data-spillage-action-view-details_${flaggedPostId}`);
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupContentFlagging, createPost} from './../support';
|
||||
|
||||
/**
|
||||
* @objective Verify that a deletion report summary is posted to the reviewer's content review thread after removing a flagged post
|
||||
* @testcase
|
||||
* 1. Create two users and set one as reviewer
|
||||
* 2. Setup content flagging
|
||||
* 3. Create a post, flag it, and remove it via reviewer
|
||||
* 4. Login as the reviewer and navigate to the content review DM
|
||||
* 5. Verify the deletion report summary table is posted in the reviewer's thread
|
||||
*/
|
||||
test('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
|
||||
const {adminClient, team, user: reviewerUser, userClient: reviewerUserClient} = await pw.initSetup();
|
||||
|
||||
// Create author user..
|
||||
const authorUser = await pw.random.user('author');
|
||||
const {id: authorUserID} = await adminClient.createUser(authorUser, '', '');
|
||||
await adminClient.addToTeam(team.id, authorUserID);
|
||||
const {client: authorUserClient} = await pw.makeClient(authorUser);
|
||||
|
||||
await setupContentFlagging(adminClient, [reviewerUser.id]);
|
||||
|
||||
const message = `Sensitive 2 post by @${authorUser.username} to be removed`;
|
||||
const {post} = await createPost(adminClient, authorUserClient, team, authorUser, message);
|
||||
|
||||
// Flag and remove the post
|
||||
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message contains sensitive data');
|
||||
|
||||
// Login as reviewer and navigate to content review DM
|
||||
const {channelsPage} = await pw.testBrowser.login(reviewerUser);
|
||||
await channelsPage.goto(team.name, '@content-review');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.centerView.getLastPost();
|
||||
await lastPost.toContainText(message);
|
||||
|
||||
await reviewerUserClient.removeFlaggedPost(post.id, 'Removing: data spillage confirmed');
|
||||
|
||||
await channelsPage.goto(team.name, '@content-review');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
await lastPost.toContainText('Content deleted as part of Content Flagging review process');
|
||||
|
||||
const viewDetailButton = await channelsPage.getFlaggedPostViewDetailButton(post.id);
|
||||
await viewDetailButton.click();
|
||||
|
||||
await channelsPage.sidebarRight.toBeVisible();
|
||||
|
||||
// Verify the summary table headers are present (rendered as markdown table)
|
||||
await channelsPage.sidebarRight.toContainText('Step');
|
||||
await channelsPage.sidebarRight.toContainText('Status');
|
||||
await channelsPage.sidebarRight.toContainText('Detail');
|
||||
|
||||
await channelsPage.sidebarRight.toContainText('File attachments');
|
||||
await channelsPage.sidebarRight.toContainText('File attachment records');
|
||||
await channelsPage.sidebarRight.toContainText('Edit history');
|
||||
await channelsPage.sidebarRight.toContainText('Priority metadata');
|
||||
await channelsPage.sidebarRight.toContainText('Persistent notifications');
|
||||
await channelsPage.sidebarRight.toContainText('Acknowledgements');
|
||||
await channelsPage.sidebarRight.toContainText('Reminders');
|
||||
await channelsPage.sidebarRight.toContainText('Thread, replies, and reactions');
|
||||
await channelsPage.sidebarRight.toContainText('Post record');
|
||||
|
||||
// Verify file attachment is present with the expected filename pattern
|
||||
const rhsLastPost = await channelsPage.sidebarRight.getLastPost();
|
||||
const expectedFileName = `deletion_report_${post.id}.md`;
|
||||
await expect(rhsLastPost.container).toContainText(expectedFileName);
|
||||
});
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/public/utils"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -585,16 +587,25 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.
|
||||
return model.NewAppError("PermanentlyRemoveFlaggedPost", "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
appErr = a.PermanentDeletePostDataRetainStub(rctx, flaggedPost, reviewerId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
groupId, err := a.ContentFlaggingGroupId()
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentDeleteFlaggedPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
deletionReport, appErr := a.PermanentDeletePostDataRetainStub(rctx, flaggedPost, reviewerId)
|
||||
|
||||
// Send the deletion report even if there is an error as there can be partial deletion of data
|
||||
// which must be reported to the reviewers.
|
||||
if deletionReport != nil {
|
||||
a.Srv().Go(func() {
|
||||
a.sendDeletionReportToReviewers(rctx, flaggedPost.Id, deletionReport, groupId)
|
||||
})
|
||||
}
|
||||
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
@@ -655,67 +666,193 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeletePostDataRetainStub(rctx request.CTX, post *model.Post, deleteByID string) *model.AppError {
|
||||
// when a post is removed, the following things need to be done
|
||||
// 1. Hard delete corresponding file infos - covered
|
||||
// 2. Hard delete file infos associated to post's edit history - NA
|
||||
// 3. Hard delete post's edit history - NA
|
||||
// 4. Hard delete the files from file storage - covered
|
||||
// 5. Hard delete post's priority data - missing
|
||||
// 6. Hard delete post's post acknowledgements - missing
|
||||
// 7. Hard delete post reminders - missing
|
||||
// 8. Scrub the post's content - message, props - missing
|
||||
func (a *App) sendDeletionReportToReviewers(rctx request.CTX, flaggedPostId string, report *model.PostDeletionReport, contentFlaggingGroupId string) {
|
||||
reportFileName := fmt.Sprintf("deletion_report_%s.md", flaggedPostId)
|
||||
|
||||
editHistories, appErr := a.GetEditHistoryForPost(post.Id)
|
||||
_, appErr := a.postReviewerMessage(rctx, "", contentFlaggingGroupId, flaggedPostId, report, reportFileName)
|
||||
if appErr != nil {
|
||||
if appErr.StatusCode != http.StatusNotFound {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get edit history for post", mlog.Err(appErr), mlog.String("post_id", post.Id))
|
||||
rctx.Logger().Error("Failed to send deletion report to reviewers", mlog.Err(appErr), mlog.String("post_id", flaggedPostId))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeletePostDataRetainStub(rctx request.CTX, post *model.Post, deleteByID string) (*model.PostDeletionReport, *model.AppError) {
|
||||
report := &model.PostDeletionReport{
|
||||
PostID: post.Id,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
a.deleteFiles(rctx, post.Id, report)
|
||||
a.deleteEditHistories(rctx, post.Id, deleteByID, report)
|
||||
|
||||
var nfErr *store.ErrNotFound
|
||||
|
||||
// Handling persistent notification
|
||||
persistentNotification, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
|
||||
if err != nil && !errors.As(err, &nfErr) {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get persistent notification for the post", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
}
|
||||
|
||||
if (err == nil && persistentNotification == nil) || errors.As(err, &nfErr) {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.persistent_notifications"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil)
|
||||
} else {
|
||||
if deleteErr := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); deleteErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete persistent notifications for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id))
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.persistent_notifications"), model.StepFailed, "", []string{deleteErr.Error()})
|
||||
} else {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.persistent_notifications"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
for _, editHistory := range editHistories {
|
||||
if deletePostAppErr := a.PermanentDeletePost(rctx, editHistory.Id, deleteByID); deletePostAppErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete one of the edit history posts", mlog.Err(deletePostAppErr), mlog.String("post_id", editHistory.Id))
|
||||
// Handling post acknowledgements
|
||||
acknowledgements, appErr := a.GetAcknowledgementsForPost(post.Id)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get post acknowledgements for the post", mlog.Err(appErr), mlog.String("post_id", post.Id))
|
||||
}
|
||||
|
||||
if appErr == nil && len(acknowledgements) == 0 {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.acknowledgements"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil)
|
||||
} else {
|
||||
if deleteErr := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(post.Id); deleteErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post acknowledgements for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id))
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.acknowledgements"), model.StepFailed, "", []string{deleteErr.Error()})
|
||||
} else {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.acknowledgements"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, post.Id); filesDeleteAppErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete files for the post", mlog.Err(filesDeleteAppErr), mlog.String("post_id", post.Id))
|
||||
// Handling post priority
|
||||
postPriorityData, appErr := a.GetPriorityForPost(post.Id)
|
||||
if appErr != nil {
|
||||
// we can still attempt a deletion even if retrieval failed
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get post priority for the post", mlog.Err(appErr), mlog.String("post_id", post.Id))
|
||||
}
|
||||
|
||||
if err := a.DeletePriorityForPost(post.Id); err != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post priority for the post", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
if appErr == nil && postPriorityData == nil {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.priority_data"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil)
|
||||
} else {
|
||||
if deleteErr := a.DeletePriorityForPost(post.Id); deleteErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post priority for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id))
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.priority_data"), model.StepFailed, "", []string{deleteErr.Error()})
|
||||
} else {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.priority_data"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(post.Id); err != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post acknowledgements for the post", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
reminders, err := a.Srv().Store().Post().GetPostRemindersForPost(post.Id)
|
||||
if err != nil && !errors.As(err, &nfErr) {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get post reminders for the post", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Post().DeleteAllPostRemindersForPost(post.Id); err != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post reminders for the post", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
if (err == nil && len(reminders) == 0) || errors.As(err, &nfErr) {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.reminders"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil)
|
||||
} else {
|
||||
if deleteErr := a.Srv().Store().Post().DeleteAllPostRemindersForPost(post.Id); deleteErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post reminders for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id))
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.reminders"), model.StepFailed, "", []string{deleteErr.Error()})
|
||||
} else {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.reminders"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Post().PermanentDeleteAssociatedData([]string{post.Id}); err != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete associated data for the post", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
if deleteErr := a.Srv().Store().Post().PermanentDeleteAssociatedData([]string{post.Id}); deleteErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete associated data for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id))
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.thread_data"), model.StepFailed, "", []string{deleteErr.Error()})
|
||||
} else {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.thread_data"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.thread_data_deleted"), nil)
|
||||
}
|
||||
|
||||
postStepErrors := []string{}
|
||||
postStepFailed := false
|
||||
|
||||
scrubPost(post)
|
||||
_, err := a.Srv().Store().Post().Overwrite(rctx, post)
|
||||
_, err = a.Srv().Store().Post().Overwrite(rctx, post)
|
||||
if err != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to scrub post content", mlog.Err(err), mlog.String("post_id", post.Id))
|
||||
postStepErrors = append(postStepErrors, fmt.Sprintf("Failed to scrub post content: %s", err.Error()))
|
||||
postStepFailed = true
|
||||
}
|
||||
|
||||
// If the post is not already deleted, delete it now.
|
||||
var deletePostErr *model.AppError
|
||||
if post.DeleteAt == 0 {
|
||||
// DeletePost is called to care of WebSocket events, cache invalidation, search index removal,
|
||||
// persistent notification removal and other cleanup tasks that need to happen on post deletion.
|
||||
_, appErr = a.DeletePost(rctx, post.Id, deleteByID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
// and other cleanup tasks that need to happen on post deletion.
|
||||
_, deletePostErr = a.DeletePost(rctx, post.Id, deleteByID)
|
||||
if deletePostErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete the post after scrubbing content", mlog.Err(deletePostErr), mlog.String("post_id", post.Id))
|
||||
|
||||
postStepErrors = append(postStepErrors, deletePostErr.Error())
|
||||
postStepFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if postStepFailed {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.post_itself"), model.StepFailed, "", postStepErrors)
|
||||
} else {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.post_itself"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.post_scrubbed_deleted"), nil)
|
||||
}
|
||||
|
||||
return report, deletePostErr
|
||||
}
|
||||
|
||||
func (a *App) deleteEditHistories(rctx request.CTX, postId, deleteByID string, report *model.PostDeletionReport) {
|
||||
editHistories, appErr := a.GetEditHistoryForPost(postId)
|
||||
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get edit history for post", mlog.Err(appErr), mlog.String("post_id", postId))
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.edit_histories"), model.StepFailed, i18n.TranslationId("app.data_spillage.report.detail.failed_retrieve_edit_history"), []string{appErr.Error()})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(editHistories) == 0 {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.edit_histories"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil)
|
||||
return
|
||||
}
|
||||
|
||||
step := model.DeletionStepResult{
|
||||
Name: i18n.TranslationId("app.data_spillage.report.step.edit_histories"),
|
||||
SubSteps: make([]model.DeletionSubStep, 0, len(editHistories)),
|
||||
}
|
||||
|
||||
allSuccess := true
|
||||
anySuccess := false
|
||||
|
||||
for _, editHistory := range editHistories {
|
||||
subStep := model.DeletionSubStep{Name: editHistory.Id}
|
||||
|
||||
if deletePostAppErr := a.PermanentDeletePost(rctx, editHistory.Id, deleteByID); deletePostAppErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete one of the edit history posts", mlog.Err(deletePostAppErr), mlog.String("post_id", editHistory.Id))
|
||||
subStep.Status = model.StepFailed
|
||||
subStep.Errors = []string{deletePostAppErr.Error()}
|
||||
allSuccess = false
|
||||
} else {
|
||||
subStep.Status = model.StepSuccess
|
||||
anySuccess = true
|
||||
}
|
||||
|
||||
step.SubSteps = append(step.SubSteps, subStep)
|
||||
}
|
||||
|
||||
if allSuccess {
|
||||
step.Status = model.StepSuccess
|
||||
} else if anySuccess {
|
||||
step.Status = model.StepPartial
|
||||
} else {
|
||||
step.Status = model.StepFailed
|
||||
}
|
||||
|
||||
cleared := model.CountSubStepSuccesses(step.SubSteps)
|
||||
total := len(step.SubSteps)
|
||||
step.Detail = i18n.TranslationId("app.data_spillage.report.detail.revisions_cleared")
|
||||
step.DetailParams = map[string]any{"Count": cleared, "Total": total}
|
||||
report.Steps = append(report.Steps, step)
|
||||
}
|
||||
|
||||
func (a *App) deleteFiles(rctx request.CTX, postId string, report *model.PostDeletionReport) {
|
||||
appErr := a.PermanentDeleteFilesByPost(rctx, postId, report)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete files for the post", mlog.Err(appErr), mlog.String("post_id", postId))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
|
||||
@@ -1072,7 +1209,7 @@ func (a *App) postAssignReviewerMessage(rctx request.CTX, contentFlaggingGroupId
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("@%s was assigned as a reviewer by @%s", reviewerUser.Username, assignedByUser.Username)
|
||||
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
|
||||
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId, nil, "")
|
||||
}
|
||||
|
||||
func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) {
|
||||
@@ -1086,7 +1223,7 @@ func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, act
|
||||
message = fmt.Sprintf("%s\n\nWith comment:\n\n> %s", message, comment)
|
||||
}
|
||||
|
||||
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
|
||||
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId, nil, "")
|
||||
}
|
||||
|
||||
func (a *App) postKeepPostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) {
|
||||
@@ -1100,7 +1237,7 @@ func (a *App) postKeepPostReviewerMessage(rctx request.CTX, flaggedPostId, actor
|
||||
message = fmt.Sprintf("%s\n\nWith comment:\n\n> %s", message, comment)
|
||||
}
|
||||
|
||||
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
|
||||
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId, nil, "")
|
||||
}
|
||||
|
||||
func (a *App) getReporterUserId(flaggedPostId, contentFlaggingGroupId string) (string, *model.AppError) {
|
||||
@@ -1169,7 +1306,7 @@ func (a *App) postMessageToReporter(rctx request.CTX, contentFlaggingGroupId str
|
||||
return a.postContentReviewBotMessage(rctx, message, userId)
|
||||
}
|
||||
|
||||
func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGroupId, flaggedPostId string) ([]*model.Post, *model.AppError) {
|
||||
func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGroupId, flaggedPostId string, report *model.PostDeletionReport, reportFileName string) ([]*model.Post, *model.AppError) {
|
||||
mappedFields, appErr := a.GetContentFlaggingMappedFields(contentFlaggingGroupId)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
@@ -1204,14 +1341,45 @@ func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGrou
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the post message and file data, localizing per-reviewer if a report is provided
|
||||
postMessage := message
|
||||
var postFileData []byte
|
||||
var postFileName string
|
||||
|
||||
if report != nil {
|
||||
T := i18n.GetUserTranslations("")
|
||||
// Fetch reviewer user to get their locale
|
||||
reviewerUserId := channel.GetOtherUserIdForDM(reviewerPost.UserId)
|
||||
reviewer, userErr := a.GetUser(reviewerUserId)
|
||||
if userErr != nil {
|
||||
rctx.Logger().Error("Failed to get reviewer user for localization, falling back to default locale", mlog.Err(userErr), mlog.String("user_id", reviewerPost.UserId))
|
||||
} else {
|
||||
T = i18n.GetUserTranslations(reviewer.Locale)
|
||||
}
|
||||
|
||||
postMessage = report.RenderSummary(T)
|
||||
postFileData = []byte(report.Render(T))
|
||||
postFileName = reportFileName
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
Message: message,
|
||||
Message: postMessage,
|
||||
UserId: contentReviewBot.UserId,
|
||||
ChannelId: reviewerPost.ChannelId,
|
||||
RootId: postId,
|
||||
}
|
||||
|
||||
// We can ignore the membership since the post itself is does not have a permalink
|
||||
// Upload file attachment if provided
|
||||
if len(postFileData) > 0 {
|
||||
fileInfo, uploadErr := a.UploadFile(rctx, postFileData, reviewerPost.ChannelId, postFileName)
|
||||
if uploadErr != nil {
|
||||
// When the report fails to upload, the details aren't lost as the logs of any item which failed to be deleted are still in the server logs.
|
||||
rctx.Logger().Error("Failed to upload report file attachment, appending to message", mlog.Err(uploadErr), mlog.String("post_id", postId))
|
||||
} else {
|
||||
post.FileIds = []string{fileInfo.Id}
|
||||
}
|
||||
}
|
||||
|
||||
createdPost, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
|
||||
if appErr != nil {
|
||||
rctx.Logger().Error("Failed to create assign reviewer post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("post_id", postId))
|
||||
|
||||
@@ -1747,7 +1747,7 @@ func TestPostReviewerMessage(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
testMessage := "Test reviewer message"
|
||||
_, appErr := th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
|
||||
_, appErr := th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id, nil, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify message was posted to the reviewer thread
|
||||
@@ -1804,7 +1804,7 @@ func TestPostReviewerMessage(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
testMessage := "Test message for multiple reviewers"
|
||||
_, appErr = th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
|
||||
_, appErr = th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id, nil, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify message was posted to both reviewer threads
|
||||
@@ -1853,6 +1853,41 @@ func TestPostReviewerMessage(t *testing.T) {
|
||||
require.Equal(t, contentReviewBot.UserId, testMessagePost2.UserId)
|
||||
})
|
||||
|
||||
t.Run("should post message from report with file attachment", func(t *testing.T) {
|
||||
require.Nil(t, setBaseConfig(th))
|
||||
|
||||
post := setupFlaggedPost(t, th)
|
||||
|
||||
groupId, err := th.App.ContentFlaggingGroupId()
|
||||
require.Nil(t, err)
|
||||
|
||||
report := &model.PostDeletionReport{
|
||||
PostID: post.Id,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
report.AddStep("app.data_spillage.report.step.post_content", model.StepSuccess, "app.data_spillage.report.detail.cleared", nil)
|
||||
report.AddStep("app.data_spillage.report.step.file_attachments", model.StepFailed, "app.data_spillage.report.detail.failed", []string{"file not found"})
|
||||
|
||||
reportFileName := "deletion_report.md"
|
||||
createdPosts, appErr := th.App.postReviewerMessage(th.Context, "", groupId, post.Id, report, reportFileName)
|
||||
require.Nil(t, appErr)
|
||||
require.NotEmpty(t, createdPosts)
|
||||
|
||||
// Verify the message content is derived from report.RenderSummary, not the passed-in message string
|
||||
createdPost := createdPosts[0]
|
||||
require.NotEmpty(t, createdPost.Message)
|
||||
// Verify it contains summary table markers that RenderSummary produces
|
||||
require.Contains(t, createdPost.Message, "📊")
|
||||
|
||||
// Verify file attachment was created
|
||||
require.NotEmpty(t, createdPost.FileIds, "expected a file attachment from the report")
|
||||
|
||||
// Verify the file info exists and has the correct name
|
||||
fileInfo, appErr := th.App.GetFileInfo(th.Context, createdPost.FileIds[0])
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, reportFileName, fileInfo.Name)
|
||||
})
|
||||
|
||||
t.Run("should handle case when no reviewer posts exist", func(t *testing.T) {
|
||||
require.Nil(t, setBaseConfig(th))
|
||||
post := th.CreatePost(t, th.BasicChannel)
|
||||
@@ -1861,7 +1896,7 @@ func TestPostReviewerMessage(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
testMessage := "Test message for non-flagged post"
|
||||
_, appErr := th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
|
||||
_, appErr := th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id, nil, "")
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
@@ -1874,7 +1909,7 @@ func TestPostReviewerMessage(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
testMessage := "Test message with special chars: @user #channel ~team & <script>alert('xss')</script>"
|
||||
_, appErr := th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id)
|
||||
_, appErr := th.App.postReviewerMessage(th.Context, testMessage, groupId, post.Id, nil, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify message was posted correctly with special characters preserved
|
||||
@@ -2589,6 +2624,207 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermanentDeleteFlaggedPostReport(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.Nil(t, setBaseConfig(th))
|
||||
|
||||
// Upload real files and create a post with those attachments to exercise all report steps
|
||||
fileInfo1, appErr := th.App.DoUploadFile(th.Context, time.Now(), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "report_file1.txt", []byte("file content 1"), true)
|
||||
require.Nil(t, appErr)
|
||||
fileInfo2, appErr := th.App.DoUploadFile(th.Context, time.Now(), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "report_file2.txt", []byte("file content 2"), true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "post with real file attachments",
|
||||
FileIds: []string{fileInfo1.Id, fileInfo2.Id},
|
||||
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Create edit history
|
||||
editedPost := post.Clone()
|
||||
editedPost.Message = "Edited for report test"
|
||||
editedPost.EditAt = model.GetMillis()
|
||||
_, _, appErr = th.App.UpdatePost(th.Context, editedPost, &model.UpdatePostOptions{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
report, appErr := th.App.PermanentDeletePostDataRetainStub(th.Context, editedPost, th.SystemAdminUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, report)
|
||||
|
||||
// Validate report metadata
|
||||
require.Equal(t, post.Id, report.PostID)
|
||||
require.False(t, report.Timestamp.IsZero())
|
||||
|
||||
// All steps should succeed (or be marked not-applicable)
|
||||
successCount, failedCount, partialCount, notApplicableCount := report.CountStatuses()
|
||||
require.Equal(t, len(report.Steps), successCount+notApplicableCount, "all steps should succeed or be not applicable")
|
||||
require.Equal(t, 0, failedCount)
|
||||
require.Equal(t, 0, partialCount)
|
||||
|
||||
// Collect steps into a map for targeted assertions
|
||||
stepsByName := make(map[string]*model.DeletionStepResult)
|
||||
for i := range report.Steps {
|
||||
stepsByName[report.Steps[i].Name] = &report.Steps[i]
|
||||
}
|
||||
|
||||
// Verify all expected step names are present
|
||||
for _, name := range []string{
|
||||
"app.data_spillage.report.step.file_attachments",
|
||||
"app.data_spillage.report.step.fileinfo_rows",
|
||||
"app.data_spillage.report.step.edit_histories",
|
||||
"app.data_spillage.report.step.priority_data",
|
||||
"app.data_spillage.report.step.persistent_notifications",
|
||||
"app.data_spillage.report.step.acknowledgements",
|
||||
"app.data_spillage.report.step.reminders",
|
||||
"app.data_spillage.report.step.thread_data",
|
||||
"app.data_spillage.report.step.post_itself",
|
||||
} {
|
||||
require.Contains(t, stepsByName, name, "report should contain step %s", name)
|
||||
}
|
||||
|
||||
// File steps should report details about the deleted files
|
||||
fileCount, ok := stepsByName["app.data_spillage.report.step.file_attachments"].DetailParams["Count"].(int)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 2, fileCount)
|
||||
|
||||
// Edit history step should have sub-steps for each revision
|
||||
editStep := stepsByName["app.data_spillage.report.step.edit_histories"]
|
||||
require.NotEmpty(t, editStep.SubSteps, "edit_histories step should have sub-steps for each revision")
|
||||
for _, subStep := range editStep.SubSteps {
|
||||
require.Equal(t, model.StepSuccess, subStep.Status)
|
||||
require.NotEmpty(t, subStep.Name, "sub-step should reference the edit history post ID")
|
||||
}
|
||||
|
||||
// Verify report renders to non-empty markdown containing key sections
|
||||
T := func(id string, args ...any) string { return id }
|
||||
rendered := report.Render(T)
|
||||
require.Contains(t, rendered, post.Id)
|
||||
require.Contains(t, rendered, "app.data_spillage.report.title")
|
||||
require.Contains(t, rendered, "app.data_spillage.report.summary")
|
||||
|
||||
summary := report.RenderSummary(T)
|
||||
require.Contains(t, summary, "app.data_spillage.report.summary")
|
||||
}
|
||||
|
||||
func TestDeleteEditHistories(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
findEditStep := func(report *model.PostDeletionReport) *model.DeletionStepResult {
|
||||
for i := range report.Steps {
|
||||
if report.Steps[i].Name == "app.data_spillage.report.step.edit_histories" {
|
||||
return &report.Steps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("no edit history", func(t *testing.T) {
|
||||
post := th.CreatePost(t, th.BasicChannel)
|
||||
report := &model.PostDeletionReport{PostID: post.Id}
|
||||
|
||||
th.App.deleteEditHistories(th.Context, post.Id, th.SystemAdminUser.Id, report)
|
||||
|
||||
step := findEditStep(report)
|
||||
require.NotNil(t, step)
|
||||
require.Equal(t, model.StepNotApplicable, step.Status)
|
||||
require.Equal(t, "app.data_spillage.report.detail.no_data_found", step.Detail)
|
||||
require.Empty(t, step.SubSteps)
|
||||
})
|
||||
|
||||
t.Run("single revision deleted successfully", func(t *testing.T) {
|
||||
post := th.CreatePost(t, th.BasicChannel)
|
||||
edited := post.Clone()
|
||||
edited.Message = "edited v1"
|
||||
edited.EditAt = model.GetMillis()
|
||||
_, _, appErr := th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
report := &model.PostDeletionReport{PostID: post.Id}
|
||||
th.App.deleteEditHistories(th.Context, post.Id, th.SystemAdminUser.Id, report)
|
||||
|
||||
step := findEditStep(report)
|
||||
require.NotNil(t, step)
|
||||
require.Equal(t, model.StepSuccess, step.Status)
|
||||
require.Len(t, step.SubSteps, 1)
|
||||
require.Equal(t, model.StepSuccess, step.SubSteps[0].Status)
|
||||
require.NotEmpty(t, step.SubSteps[0].Name)
|
||||
require.Equal(t, 1, step.DetailParams["Count"])
|
||||
require.Equal(t, 1, step.DetailParams["Total"])
|
||||
})
|
||||
|
||||
t.Run("multiple revisions all deleted", func(t *testing.T) {
|
||||
post := th.CreatePost(t, th.BasicChannel)
|
||||
for i := range 3 {
|
||||
edited := post.Clone()
|
||||
edited.Message = fmt.Sprintf("edit %d", i)
|
||||
edited.EditAt = model.GetMillis()
|
||||
_, _, appErr := th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
|
||||
require.Nil(t, appErr)
|
||||
}
|
||||
|
||||
report := &model.PostDeletionReport{PostID: post.Id}
|
||||
th.App.deleteEditHistories(th.Context, post.Id, th.SystemAdminUser.Id, report)
|
||||
|
||||
step := findEditStep(report)
|
||||
require.NotNil(t, step)
|
||||
require.Equal(t, model.StepSuccess, step.Status)
|
||||
require.Len(t, step.SubSteps, 3)
|
||||
for _, sub := range step.SubSteps {
|
||||
require.Equal(t, model.StepSuccess, sub.Status)
|
||||
}
|
||||
require.Equal(t, 3, step.DetailParams["Count"])
|
||||
require.Equal(t, 3, step.DetailParams["Total"])
|
||||
})
|
||||
|
||||
t.Run("revision with file attachments reports file names", func(t *testing.T) {
|
||||
post := th.CreatePost(t, th.BasicChannel)
|
||||
edited := post.Clone()
|
||||
edited.Message = "edited with files"
|
||||
edited.EditAt = model.GetMillis()
|
||||
_, _, appErr := th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Attach a file to the edit history post to exercise the file-reporting code path
|
||||
editHistories, appErr := th.App.GetEditHistoryForPost(post.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, editHistories, 1)
|
||||
|
||||
fi := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
PostId: editHistories[0].Id,
|
||||
CreatorId: post.UserId,
|
||||
Path: "test/edit_file.txt",
|
||||
Name: "edit_file.txt",
|
||||
Size: 50,
|
||||
}
|
||||
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fi)
|
||||
require.NoError(t, err)
|
||||
|
||||
report := &model.PostDeletionReport{PostID: post.Id}
|
||||
th.App.deleteEditHistories(th.Context, post.Id, th.SystemAdminUser.Id, report)
|
||||
|
||||
step := findEditStep(report)
|
||||
require.NotNil(t, step)
|
||||
require.Len(t, step.SubSteps, 1)
|
||||
})
|
||||
|
||||
t.Run("nonexistent post reports no revisions", func(t *testing.T) {
|
||||
report := &model.PostDeletionReport{PostID: model.NewId()}
|
||||
th.App.deleteEditHistories(th.Context, model.NewId(), th.SystemAdminUser.Id, report)
|
||||
|
||||
step := findEditStep(report)
|
||||
require.NotNil(t, step)
|
||||
require.Equal(t, model.StepNotApplicable, step.Status)
|
||||
require.Equal(t, "app.data_spillage.report.detail.no_data_found", step.Detail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeepFlaggedPost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
+60
-11
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/imaging"
|
||||
@@ -1761,21 +1762,58 @@ func getFileExtFromMimeType(mimeType string) string {
|
||||
return "jpg"
|
||||
}
|
||||
|
||||
func (a *App) PermanentDeleteFilesByPost(rctx request.CTX, postID string) *model.AppError {
|
||||
func (a *App) PermanentDeleteFilesByPost(rctx request.CTX, postID string, report *model.PostDeletionReport) *model.AppError {
|
||||
fileInfos, err := a.Srv().Store().FileInfo().GetForPost(postID, false, true, true)
|
||||
if err != nil {
|
||||
if report != nil {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.file_attachments"), model.StepFailed, "", []string{err.Error()})
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.fileinfo_rows"), model.StepFailed, "", []string{err.Error()})
|
||||
}
|
||||
|
||||
return model.NewAppError("PermanentDeleteFilesByPost", "app.file_info.get_by_post_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if len(fileInfos) == 0 {
|
||||
rctx.Logger().Debug("No files found for post", mlog.String("post_id", postID))
|
||||
|
||||
if report != nil {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.file_attachments"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_files"), nil)
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.fileinfo_rows"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_rows_to_delete"), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
a.RemoveFilesFromFileStore(rctx, fileInfos)
|
||||
fileInfoIDs := make([]string, 0, len(fileInfos))
|
||||
for _, fileInfo := range fileInfos {
|
||||
fileInfoIDs = append(fileInfoIDs, fmt.Sprintf("`%s`", fileInfo.Id))
|
||||
}
|
||||
|
||||
errs := a.RemoveFilesFromFileStore(rctx, fileInfos)
|
||||
if len(errs) > 0 {
|
||||
if report != nil {
|
||||
errMessages := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
errMessages = append(errMessages, err.Error())
|
||||
}
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.file_attachments"), model.StepFailed, "", errMessages)
|
||||
}
|
||||
} else {
|
||||
if report != nil {
|
||||
report.AddStepWithParams(i18n.TranslationId("app.data_spillage.report.step.file_attachments"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.file_names"), map[string]any{"Count": len(fileInfos)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
err = a.Srv().Store().FileInfo().PermanentDeleteForPost(rctx, postID)
|
||||
if err != nil {
|
||||
return model.NewAppError("PermanentDeleteFilesByPost", "app.file_info.permanent_delete_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
if report != nil {
|
||||
report.AddStep(i18n.TranslationId("app.data_spillage.report.step.fileinfo_rows"), model.StepFailed, "", []string{err.Error()})
|
||||
}
|
||||
|
||||
return model.NewAppError("PermanentDeleteFilesByPost", i18n.TranslationId("app.file_info.permanent_delete_for_post.app_error"), nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if report != nil {
|
||||
report.AddStepWithParams(i18n.TranslationId("app.data_spillage.report.step.fileinfo_rows"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.file_attachments_info_ids"), map[string]any{"FileInfoIDs": strings.Join(fileInfoIDs, ", ")}, nil)
|
||||
}
|
||||
|
||||
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, true)
|
||||
@@ -1784,19 +1822,28 @@ func (a *App) PermanentDeleteFilesByPost(rctx request.CTX, postID string) *model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RemoveFilesFromFileStore(rctx request.CTX, fileInfos []*model.FileInfo) {
|
||||
func (a *App) RemoveFilesFromFileStore(rctx request.CTX, fileInfos []*model.FileInfo) []*model.AppError {
|
||||
errs := []*model.AppError{}
|
||||
|
||||
for _, info := range fileInfos {
|
||||
a.RemoveFileFromFileStore(rctx, info.Path)
|
||||
appErr := a.RemoveFileFromFileStore(rctx, info.Path)
|
||||
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
|
||||
newAppErr := model.NewAppError("RemoveFilesFromFileStore", "app.file_info.remove_file.app_error", map[string]any{"FileInfoID": info.Id}, "", http.StatusInternalServerError)
|
||||
errs = append(errs, newAppErr)
|
||||
}
|
||||
|
||||
if info.PreviewPath != "" {
|
||||
a.RemoveFileFromFileStore(rctx, info.PreviewPath)
|
||||
_ = a.RemoveFileFromFileStore(rctx, info.PreviewPath)
|
||||
}
|
||||
if info.ThumbnailPath != "" {
|
||||
a.RemoveFileFromFileStore(rctx, info.ThumbnailPath)
|
||||
_ = a.RemoveFileFromFileStore(rctx, info.ThumbnailPath)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) {
|
||||
func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) *model.AppError {
|
||||
res, appErr := a.FileExists(path)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn(
|
||||
@@ -1804,12 +1851,12 @@ func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) {
|
||||
mlog.String("path", path),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return
|
||||
return appErr
|
||||
}
|
||||
|
||||
if !res {
|
||||
rctx.Logger().Warn("File not found", mlog.String("path", path))
|
||||
return
|
||||
return model.NewAppError("RemoveFileFromFile", "app.file_info.not_found", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
appErr = a.RemoveFile(path)
|
||||
@@ -1819,8 +1866,10 @@ func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) {
|
||||
mlog.String("path", path),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return
|
||||
return appErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendFileDownloadRejectedEvent sends a websocket event to notify the user that their file download was rejected.
|
||||
|
||||
@@ -820,7 +820,7 @@ func TestPermanentDeleteFilesByPost(t *testing.T) {
|
||||
post, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id)
|
||||
err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id, nil)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = th.App.GetFileInfo(th.Context, info1.Id)
|
||||
@@ -828,7 +828,7 @@ func TestPermanentDeleteFilesByPost(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should not delete files for post that doesn't exist", func(t *testing.T) {
|
||||
err := th.App.PermanentDeleteFilesByPost(th.Context, "postId1")
|
||||
err := th.App.PermanentDeleteFilesByPost(th.Context, "postId1", nil)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
@@ -844,9 +844,65 @@ func TestPermanentDeleteFilesByPost(t *testing.T) {
|
||||
post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id)
|
||||
err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id, nil)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should mark both report steps as failed on GetForPost store error", func(t *testing.T) {
|
||||
mockTh := SetupWithStoreMock(t)
|
||||
|
||||
mockStore := mockTh.App.Srv().Store().(*storemocks.Store)
|
||||
mockFileStore := storemocks.FileInfoStore{}
|
||||
mockFileStore.On("GetForPost", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("db connection lost"))
|
||||
mockStore.On("FileInfo").Return(&mockFileStore)
|
||||
|
||||
report := &model.PostDeletionReport{
|
||||
PostID: "test-post-id",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
appErr := mockTh.App.PermanentDeleteFilesByPost(request.TestContext(t), "test-post-id", report)
|
||||
require.NotNil(t, appErr)
|
||||
|
||||
// Both file_attachments and fileinfo_rows steps should be marked as failed
|
||||
require.Len(t, report.Steps, 2)
|
||||
require.Equal(t, model.StepFailed, report.Steps[0].Status)
|
||||
require.Equal(t, model.StepFailed, report.Steps[1].Status)
|
||||
require.Contains(t, report.Steps[0].Errors[0], "db connection lost")
|
||||
require.Contains(t, report.Steps[1].Errors[0], "db connection lost")
|
||||
})
|
||||
|
||||
t.Run("should mark fileinfo_rows as failed when PermanentDeleteForPost fails", func(t *testing.T) {
|
||||
mockTh := SetupWithStoreMock(t)
|
||||
|
||||
postID := model.NewId()
|
||||
|
||||
mockStore := mockTh.App.Srv().Store().(*storemocks.Store)
|
||||
mockFileStore := storemocks.FileInfoStore{}
|
||||
// Return file infos with non-existent paths so file store removal
|
||||
// returns NotFound (which is skipped), resulting in no errors.
|
||||
mockFileStore.On("GetForPost", postID, false, true, true).Return([]*model.FileInfo{
|
||||
{Id: model.NewId(), Name: "file1.txt", Path: "/nonexistent/file1.txt"},
|
||||
{Id: model.NewId(), Name: "file2.txt", Path: "/nonexistent/file2.txt"},
|
||||
}, nil)
|
||||
mockFileStore.On("PermanentDeleteForPost", mock.Anything, postID).Return(errors.New("foreign key constraint"))
|
||||
mockStore.On("FileInfo").Return(&mockFileStore)
|
||||
|
||||
report := &model.PostDeletionReport{
|
||||
PostID: postID,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
appErr := mockTh.App.PermanentDeleteFilesByPost(request.TestContext(t), postID, report)
|
||||
require.NotNil(t, appErr)
|
||||
|
||||
// file_attachments step should succeed (NotFound files are skipped),
|
||||
// but fileinfo_rows step should fail due to the DB error.
|
||||
require.Len(t, report.Steps, 2)
|
||||
require.Equal(t, model.StepSuccess, report.Steps[0].Status, "file_attachments step should succeed")
|
||||
require.Equal(t, model.StepFailed, report.Steps[1].Status, "fileinfo_rows step should fail")
|
||||
require.Contains(t, report.Steps[1].Errors[0], "foreign key constraint")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterFilesByChannelPermissions(t *testing.T) {
|
||||
|
||||
@@ -1901,6 +1901,13 @@ func (a *App) DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Po
|
||||
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, false)
|
||||
}
|
||||
|
||||
if post.RootId == "" {
|
||||
appErr = a.DeletePersistentNotification(rctx, post)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
}
|
||||
|
||||
appErr = a.CleanUpAfterPostDeletion(rctx, post, deleteByID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
@@ -3146,7 +3153,7 @@ func (a *App) PermanentDeletePost(rctx request.CTX, postID, deleteByID string) *
|
||||
}
|
||||
|
||||
if postHasFiles {
|
||||
appErr := a.PermanentDeleteFilesByPost(rctx, post.Id)
|
||||
appErr := a.PermanentDeleteFilesByPost(rctx, post.Id, nil)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
@@ -3157,6 +3164,12 @@ func (a *App) PermanentDeletePost(rctx request.CTX, postID, deleteByID string) *
|
||||
return model.NewAppError("PermanentDeletePost", "app.post.permanent_delete_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if post.RootId == "" {
|
||||
if appErr := a.DeletePersistentNotification(rctx, post); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
appErr := a.CleanUpAfterPostDeletion(rctx, post, deleteByID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
@@ -3171,12 +3184,6 @@ func (a *App) CleanUpAfterPostDeletion(rctx request.CTX, post *model.Post, delet
|
||||
return appErr
|
||||
}
|
||||
|
||||
if post.RootId == "" {
|
||||
if appErr := a.DeletePersistentNotification(rctx, post); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
}
|
||||
|
||||
postJSON, err := json.Marshal(post)
|
||||
if err != nil {
|
||||
return model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
@@ -3921,7 +3928,8 @@ func (a *App) BurnPost(rctx request.CTX, post *model.Post, userID string, connec
|
||||
|
||||
// If user is the author, permanently delete the post
|
||||
if post.UserId == userID {
|
||||
return a.PermanentDeletePostDataRetainStub(rctx, post, userID)
|
||||
_, appErr := a.PermanentDeletePostDataRetainStub(rctx, post, userID)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// If not the author, check read receipt
|
||||
|
||||
@@ -952,6 +952,52 @@ func TestDeletePostInArchivedChannel(t *testing.T) {
|
||||
require.Equal(t, "api.post.delete_post.can_not_delete_post_in_deleted.error", err.Id)
|
||||
}
|
||||
|
||||
func TestDeletePostDeletesPersistentNotification(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.PostPriority = true
|
||||
*cfg.ServiceSettings.AllowPersistentNotifications = true
|
||||
})
|
||||
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
t.Run("should delete persistent notification for root post", func(t *testing.T) {
|
||||
post := &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "urgent " + "@" + th.BasicUser2.Username,
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
PersistentNotifications: model.NewPointer(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
post, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, post.RootId, "test post must be a root post")
|
||||
|
||||
// Verify persistent notification exists
|
||||
pn, err := th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pn)
|
||||
|
||||
// Delete the post (soft delete)
|
||||
_, appErr = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify persistent notification was deleted
|
||||
_, err = th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
|
||||
var nfErr *store.ErrNotFound
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &nfErr)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
t.Run("call PreparePostForClient before returning", func(t *testing.T) {
|
||||
@@ -4772,6 +4818,47 @@ func TestPermanentDeletePost(t *testing.T) {
|
||||
assert.True(t, store.IsErrNotFound(tmpErr))
|
||||
})
|
||||
|
||||
t.Run("should delete persistent notification for root post", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.PostPriority = true
|
||||
*cfg.ServiceSettings.AllowPersistentNotifications = true
|
||||
})
|
||||
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
post := &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "urgent " + "@" + th.BasicUser2.Username,
|
||||
Metadata: &model.PostMetadata{
|
||||
Priority: &model.PostPriority{
|
||||
Priority: model.NewPointer(model.PostPriorityUrgent),
|
||||
PersistentNotifications: model.NewPointer(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
post, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, post.RootId, "test post must be a root post")
|
||||
|
||||
// Verify persistent notification exists
|
||||
pn, err := th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pn)
|
||||
|
||||
// Permanently delete the post
|
||||
appErr = th.App.PermanentDeletePost(th.Context, post.Id, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify persistent notification was deleted
|
||||
_, err = th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
|
||||
var nfErr *store.ErrNotFound
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &nfErr)
|
||||
})
|
||||
|
||||
t.Run("should send unrevealed post in websocket broadcast", func(t *testing.T) {
|
||||
// Enable feature with license
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
|
||||
type AppIface interface {
|
||||
DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
|
||||
PermanentDeletePostDataRetainStub(rctx request.CTX, post *model.Post, deleteByID string) *model.AppError
|
||||
PermanentDeletePostDataRetainStub(rctx request.CTX, post *model.Post, deleteByID string) (*model.PostDeletionReport, *model.AppError)
|
||||
GetSinglePost(rctx request.CTX, postID string, includeDeleted bool) (*model.Post, *model.AppError)
|
||||
GetPostsByIds(postIDs []string) ([]*model.Post, int64, *model.AppError)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func MakeWorker(jobServer *jobs.JobServer, store store.Store, app AppIface) *job
|
||||
}
|
||||
|
||||
for _, post := range expiredPosts {
|
||||
appErr = app.PermanentDeletePostDataRetainStub(request.EmptyContext(logger), post, "")
|
||||
_, appErr = app.PermanentDeletePostDataRetainStub(request.EmptyContext(logger), post, "")
|
||||
if appErr != nil {
|
||||
logger.Error("Failed to delete expired post", mlog.Err(appErr), mlog.String("post_id", post.Id))
|
||||
continue
|
||||
|
||||
@@ -1383,6 +1383,27 @@ func (s *RetryLayerChannelStore) AutocompleteInTeam(rctx request.CTX, teamID str
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerChannelStore) AutocompleteInTeamFiltered(rctx request.CTX, teamID string, userID string, term string, includeDeleted bool, isGuest bool, privateOnly bool, excludeGroupConstrained bool) (model.ChannelList, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ChannelStore.AutocompleteInTeamFiltered(rctx, teamID, userID, term, includeDeleted, isGuest, privateOnly, excludeGroupConstrained)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerChannelStore) AutocompleteInTeamForSearch(teamID string, userID string, term string, includeDeleted bool) (model.ChannelList, error) {
|
||||
|
||||
tries := 0
|
||||
@@ -8457,6 +8478,27 @@ func (s *RetryLayerPostStore) GetPostReminders(now int64) ([]*model.PostReminder
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) GetPostRemindersForPost(postId string) ([]*model.PostReminder, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PostStore.GetPostRemindersForPost(postId)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) GetPosts(rctx request.CTX, options model.GetPostsOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
|
||||
tries := 0
|
||||
|
||||
@@ -3236,6 +3236,20 @@ func (s *SqlPostStore) GetPostReminders(now int64) ([]*model.PostReminder, error
|
||||
return reminders, nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) GetPostRemindersForPost(postId string) ([]*model.PostReminder, error) {
|
||||
reminders := []*model.PostReminder{}
|
||||
err := s.GetMaster().Select(&reminders, `SELECT PostId, UserId, TargetTime FROM PostReminders WHERE PostId = $1`, postId)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("PostUd", postId)
|
||||
}
|
||||
|
||||
return nil, errors.Wrap(err, "failed to get post reminders")
|
||||
}
|
||||
|
||||
return reminders, nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) DeleteAllPostRemindersForPost(postId string) error {
|
||||
_, err := s.GetMaster().Exec(`DELETE from PostReminders WHERE PostId = ?`, postId)
|
||||
if err != nil {
|
||||
|
||||
@@ -421,6 +421,7 @@ type PostStore interface {
|
||||
GetPostsSinceForSync(options model.GetPostsSinceForSyncOptions, cursor model.GetPostsSinceForSyncCursor, limit int) ([]*model.Post, model.GetPostsSinceForSyncCursor, error)
|
||||
SetPostReminder(reminder *model.PostReminder) error
|
||||
GetPostReminders(now int64) ([]*model.PostReminder, error)
|
||||
GetPostRemindersForPost(postId string) ([]*model.PostReminder, error)
|
||||
DeleteAllPostRemindersForPost(postId string) error
|
||||
GetPostReminderMetadata(postID string) (*PostReminderMetadata, error)
|
||||
// GetNthRecentPostTime returns the CreateAt time of the nth most recent post.
|
||||
|
||||
@@ -652,6 +652,36 @@ func (_m *PostStore) GetPostReminders(now int64) ([]*model.PostReminder, error)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPostRemindersForPost provides a mock function with given fields: postId
|
||||
func (_m *PostStore) GetPostRemindersForPost(postId string) ([]*model.PostReminder, error) {
|
||||
ret := _m.Called(postId)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPostRemindersForPost")
|
||||
}
|
||||
|
||||
var r0 []*model.PostReminder
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string) ([]*model.PostReminder, error)); ok {
|
||||
return rf(postId)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) []*model.PostReminder); ok {
|
||||
r0 = rf(postId)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.PostReminder)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(postId)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPosts provides a mock function with given fields: rctx, options, allowFromCache, sanitizeOptions
|
||||
func (_m *PostStore) GetPosts(rctx request.CTX, options model.GetPostsOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
ret := _m.Called(rctx, options, allowFromCache, sanitizeOptions)
|
||||
|
||||
@@ -1227,6 +1227,22 @@ func (s *TimerLayerChannelStore) AutocompleteInTeam(rctx request.CTX, teamID str
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerChannelStore) AutocompleteInTeamFiltered(rctx request.CTX, teamID string, userID string, term string, includeDeleted bool, isGuest bool, privateOnly bool, excludeGroupConstrained bool) (model.ChannelList, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ChannelStore.AutocompleteInTeamFiltered(rctx, teamID, userID, term, includeDeleted, isGuest, privateOnly, excludeGroupConstrained)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.AutocompleteInTeamFiltered", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerChannelStore) AutocompleteInTeamForSearch(teamID string, userID string, term string, includeDeleted bool) (model.ChannelList, error) {
|
||||
start := time.Now()
|
||||
|
||||
@@ -6797,6 +6813,22 @@ func (s *TimerLayerPostStore) GetPostReminders(now int64) ([]*model.PostReminder
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) GetPostRemindersForPost(postId string) ([]*model.PostReminder, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PostStore.GetPostRemindersForPost(postId)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PostStore.GetPostRemindersForPost", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) GetPosts(rctx request.CTX, options model.GetPostsOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
||||
@@ -6054,6 +6054,160 @@
|
||||
"id": "app.data_spillage.permanently_delete.update_property_value.app_error",
|
||||
"translation": "Failed to update flagged post status when permanently deleting flagged post."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.cleared",
|
||||
"translation": "Cleared"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.column.detail",
|
||||
"translation": "Detail"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.column.status",
|
||||
"translation": "Status"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.column.step",
|
||||
"translation": "Step"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.deleted",
|
||||
"translation": "Deleted."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.failed_retrieve_edit_history",
|
||||
"translation": "Failed to retrieve edit history."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.file_attachments_info_ids",
|
||||
"translation": "**File Info IDs:** {{.FileInfoIDs}}"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.file_names",
|
||||
"translation": {
|
||||
"one": "Removed {{.Count}} file from disk.",
|
||||
"other": "Removed {{.Count}} files from disk."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.no_data_found",
|
||||
"translation": "No data found."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.no_files",
|
||||
"translation": "No files found."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.no_rows_to_delete",
|
||||
"translation": "No rows to delete."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.post_scrubbed_deleted",
|
||||
"translation": "Post scrubbed and deleted."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.revisions_cleared",
|
||||
"translation": {
|
||||
"one": "Cleared {{.Count}} revision of {{.Total}}.",
|
||||
"other": "Cleared {{.Count}} revisions of {{.Total}}."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.detail.thread_data_deleted",
|
||||
"translation": "Threads, reactions, and associated data deleted."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.error_log",
|
||||
"translation": "Error Log"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.generated",
|
||||
"translation": "Generated:"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.incomplete_warning",
|
||||
"translation": "Post deletion incomplete. Review the Error Log and escalate to a System Administrator for manual remediation."
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.post_id",
|
||||
"translation": "Post ID:"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.revision",
|
||||
"translation": "Revision"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.revisions_found",
|
||||
"translation": "Revisions found:"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.status.failed",
|
||||
"translation": "Failed"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.status.not_applicable",
|
||||
"translation": "Not applicable"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.status.partial",
|
||||
"translation": "Partial"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.status.removed",
|
||||
"translation": "Removed"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.status.unknown",
|
||||
"translation": "Unknown"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.acknowledgements",
|
||||
"translation": "Acknowledgements"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.edit_histories",
|
||||
"translation": "Edit history"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.file_attachments",
|
||||
"translation": "File attachments"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.fileinfo_rows",
|
||||
"translation": "File attachment records"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.persistent_notifications",
|
||||
"translation": "Persistent notifications"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.post_itself",
|
||||
"translation": "Post record"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.priority_data",
|
||||
"translation": "Priority metadata"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.reminders",
|
||||
"translation": "Reminders"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.step.thread_data",
|
||||
"translation": "Thread, replies, and reactions"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.summary",
|
||||
"translation": "Summary"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.title",
|
||||
"translation": "Post Deletion Report"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.report.total_steps",
|
||||
"translation": "Total Steps:"
|
||||
},
|
||||
{
|
||||
"id": "app.data_spillage.save_reviewer_settings.app_error",
|
||||
"translation": "Failed to save content reviewer settings to the database."
|
||||
@@ -6298,6 +6452,10 @@
|
||||
"id": "app.file_info.get_with_options.app_error",
|
||||
"translation": "Unable to get the file info with options"
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.not_found",
|
||||
"translation": "File not found in file store at path."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.permanent_delete_by_user.app_error",
|
||||
"translation": "Unable to delete attachments of the user."
|
||||
@@ -6306,6 +6464,10 @@
|
||||
"id": "app.file_info.permanent_delete_for_post.app_error",
|
||||
"translation": "Failed to permanently delete file for post."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.remove_file.app_error",
|
||||
"translation": "Failed to remove the file from file store."
|
||||
},
|
||||
{
|
||||
"id": "app.file_info.save.app_error",
|
||||
"translation": "Unable to save the file info."
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
)
|
||||
|
||||
type DeletionStepStatus int
|
||||
|
||||
const (
|
||||
StepSuccess DeletionStepStatus = iota
|
||||
StepFailed
|
||||
StepPartial
|
||||
StepNotApplicable
|
||||
)
|
||||
|
||||
func (s DeletionStepStatus) Icon() string {
|
||||
switch s {
|
||||
case StepSuccess:
|
||||
return "✅"
|
||||
case StepFailed:
|
||||
return "❌"
|
||||
case StepPartial:
|
||||
return "⚠️"
|
||||
case StepNotApplicable:
|
||||
return "➖"
|
||||
default:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
|
||||
func (s DeletionStepStatus) Label(T i18n.TranslateFunc) string {
|
||||
switch s {
|
||||
case StepSuccess:
|
||||
return T("app.data_spillage.report.status.removed")
|
||||
case StepFailed:
|
||||
return T("app.data_spillage.report.status.failed")
|
||||
case StepPartial:
|
||||
return T("app.data_spillage.report.status.partial")
|
||||
case StepNotApplicable:
|
||||
return T("app.data_spillage.report.status.not_applicable")
|
||||
default:
|
||||
return T("app.data_spillage.report.status.unknown")
|
||||
}
|
||||
}
|
||||
|
||||
type DeletionSubStep struct {
|
||||
Name string
|
||||
Status DeletionStepStatus
|
||||
Detail string
|
||||
DetailParams map[string]any
|
||||
Errors []string
|
||||
}
|
||||
|
||||
type DeletionStepResult struct {
|
||||
Name string
|
||||
Status DeletionStepStatus
|
||||
Detail string
|
||||
DetailParams map[string]any
|
||||
Errors []string
|
||||
SubSteps []DeletionSubStep
|
||||
}
|
||||
|
||||
type PostDeletionReport struct {
|
||||
PostID string
|
||||
Timestamp time.Time
|
||||
Steps []DeletionStepResult
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) AddStep(name string, status DeletionStepStatus, detail string, errs []string) {
|
||||
r.Steps = append(r.Steps, DeletionStepResult{
|
||||
Name: name,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
Errors: errs,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) AddStepWithParams(name string, status DeletionStepStatus, detail string, detailParams map[string]any, errs []string) {
|
||||
r.Steps = append(r.Steps, DeletionStepResult{
|
||||
Name: name,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
DetailParams: detailParams,
|
||||
Errors: errs,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) translateDetail(T i18n.TranslateFunc, detail string, detailParams map[string]any) string {
|
||||
if detail == "" {
|
||||
return ""
|
||||
}
|
||||
if len(detailParams) > 0 {
|
||||
if count, ok := detailParams["Count"]; ok {
|
||||
return T(detail, count, detailParams)
|
||||
}
|
||||
return T(detail, detailParams)
|
||||
}
|
||||
return T(detail)
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) Render(T i18n.TranslateFunc) string {
|
||||
var b strings.Builder
|
||||
|
||||
successCount, failedCount, partialCount, notApplicableCount := r.CountStatuses()
|
||||
totalSteps := len(r.Steps)
|
||||
|
||||
b.WriteString(fmt.Sprintf("### %s\n\n", T("app.data_spillage.report.title")))
|
||||
b.WriteString(fmt.Sprintf("**%s** %s\n", T("app.data_spillage.report.generated"), r.Timestamp.Format("2006-01-02 at 15:04:05 UTC")))
|
||||
b.WriteString(fmt.Sprintf("**%s** `%s`\n", T("app.data_spillage.report.post_id"), r.PostID))
|
||||
b.WriteString(fmt.Sprintf("**%s** %d | ✅ %s: %d | ➖ %s: %d | ⚠️ %s: %d | ❌ %s: %d\n",
|
||||
T("app.data_spillage.report.total_steps"), totalSteps,
|
||||
T("app.data_spillage.report.status.removed"), successCount,
|
||||
T("app.data_spillage.report.status.not_applicable"), notApplicableCount,
|
||||
T("app.data_spillage.report.status.partial"), partialCount,
|
||||
T("app.data_spillage.report.status.failed"), failedCount))
|
||||
b.WriteString("\n---\n\n")
|
||||
|
||||
for i, step := range r.Steps {
|
||||
stepNum := i + 1
|
||||
r.renderStep(T, &b, stepNum, step)
|
||||
}
|
||||
|
||||
b.WriteString("---\n\n")
|
||||
r.renderSummaryTable(T, &b)
|
||||
|
||||
if failedCount > 0 || partialCount > 0 {
|
||||
b.WriteString(fmt.Sprintf("\n> ⚠️ **%s**\n", T("app.data_spillage.report.incomplete_warning")))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) RenderSummary(T i18n.TranslateFunc) string {
|
||||
var b strings.Builder
|
||||
|
||||
_, failedCount, partialCount, _ := r.CountStatuses()
|
||||
|
||||
r.renderSummaryTable(T, &b)
|
||||
|
||||
if failedCount > 0 || partialCount > 0 {
|
||||
b.WriteString(fmt.Sprintf("\n> ⚠️ **%s**\n", T("app.data_spillage.report.incomplete_warning")))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) renderStep(T i18n.TranslateFunc, b *strings.Builder, num int, step DeletionStepResult) {
|
||||
translatedName := T(step.Name)
|
||||
translatedDetail := r.translateDetail(T, step.Detail, step.DetailParams)
|
||||
|
||||
if len(step.SubSteps) > 0 {
|
||||
b.WriteString(fmt.Sprintf("##### %d. %s\n\n", num, translatedName))
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
for _, sub := range step.SubSteps {
|
||||
if sub.Status == StepSuccess {
|
||||
successCount++
|
||||
} else {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**%s** %d | ✅ %s: %d | ❌ %s: %d\n\n",
|
||||
T("app.data_spillage.report.revisions_found"), len(step.SubSteps),
|
||||
T("app.data_spillage.report.cleared"), successCount,
|
||||
T("app.data_spillage.report.status.failed"), failedCount))
|
||||
|
||||
for j, sub := range step.SubSteps {
|
||||
subDetail := r.translateDetail(T, sub.Detail, sub.DetailParams)
|
||||
b.WriteString(fmt.Sprintf("###### %s %s %d — `%s`\n", sub.Status.Icon(), T("app.data_spillage.report.revision"), j+1, sub.Name))
|
||||
if subDetail != "" {
|
||||
b.WriteString(fmt.Sprintf("- %s\n", subDetail))
|
||||
}
|
||||
r.renderErrors(T, b, sub.Errors)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("##### %d. %s %s\n", num, step.Status.Icon(), translatedName))
|
||||
if translatedDetail != "" {
|
||||
b.WriteString(fmt.Sprintf("%s\n", translatedDetail))
|
||||
}
|
||||
r.renderErrors(T, b, step.Errors)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) renderErrors(T i18n.TranslateFunc, b *strings.Builder, errs []string) {
|
||||
if len(errs) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n> **%s**\n> ```\n", T("app.data_spillage.report.error_log")))
|
||||
for _, e := range errs {
|
||||
for line := range strings.SplitSeq(e, "\n") {
|
||||
b.WriteString(fmt.Sprintf("> %s\n", line))
|
||||
}
|
||||
}
|
||||
b.WriteString("> ```\n")
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) renderSummaryTable(T i18n.TranslateFunc, b *strings.Builder) {
|
||||
b.WriteString(fmt.Sprintf("##### 📊 %s\n\n", T("app.data_spillage.report.summary")))
|
||||
b.WriteString(fmt.Sprintf("| # | %s | %s | %s |\n",
|
||||
T("app.data_spillage.report.column.step"),
|
||||
T("app.data_spillage.report.column.status"),
|
||||
T("app.data_spillage.report.column.detail")))
|
||||
b.WriteString("|---|---|---|---|\n")
|
||||
for i, step := range r.Steps {
|
||||
translatedName := T(step.Name)
|
||||
translatedDetail := r.translateDetail(T, step.Detail, step.DetailParams)
|
||||
b.WriteString(fmt.Sprintf("| %d | %s | %s | %s |\n",
|
||||
i+1, translatedName, step.Status.Icon(), translatedDetail))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PostDeletionReport) CountStatuses() (success, failed, partial, notApplicable int) {
|
||||
for _, step := range r.Steps {
|
||||
switch step.Status {
|
||||
case StepSuccess:
|
||||
success++
|
||||
case StepFailed:
|
||||
failed++
|
||||
case StepPartial:
|
||||
partial++
|
||||
case StepNotApplicable:
|
||||
notApplicable++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func CountSubStepSuccesses(subSteps []DeletionSubStep) int {
|
||||
count := 0
|
||||
for _, s := range subSteps {
|
||||
if s.Status == StepSuccess {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubTranslateFunc returns translation IDs as-is, or interpolates params if provided.
|
||||
// This mimics the i18n.TranslateFunc signature for unit tests without loading real locale files.
|
||||
func stubTranslateFunc(id string, args ...any) string {
|
||||
for _, arg := range args {
|
||||
if params, ok := arg.(map[string]any); ok {
|
||||
result := id
|
||||
for k, v := range params {
|
||||
result = strings.ReplaceAll(result, fmt.Sprintf("{{.%s}}", k), fmt.Sprintf("%v", v))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestDeletionStepStatusIcon(t *testing.T) {
|
||||
tests := []struct {
|
||||
status DeletionStepStatus
|
||||
expected string
|
||||
}{
|
||||
{StepSuccess, "✅"},
|
||||
{StepFailed, "❌"},
|
||||
{StepPartial, "⚠️"},
|
||||
{StepNotApplicable, "➖"},
|
||||
{DeletionStepStatus(99), "❓"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
assert.Equal(t, tc.expected, tc.status.Icon())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletionStepStatusLabel(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
|
||||
assert.Equal(t, "app.data_spillage.report.status.removed", StepSuccess.Label(T))
|
||||
assert.Equal(t, "app.data_spillage.report.status.failed", StepFailed.Label(T))
|
||||
assert.Equal(t, "app.data_spillage.report.status.partial", StepPartial.Label(T))
|
||||
assert.Equal(t, "app.data_spillage.report.status.not_applicable", StepNotApplicable.Label(T))
|
||||
assert.Equal(t, "app.data_spillage.report.status.unknown", DeletionStepStatus(99).Label(T))
|
||||
}
|
||||
|
||||
func TestPostDeletionReportAddStep(t *testing.T) {
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("step1", StepSuccess, "detail1", nil)
|
||||
report.AddStep("step2", StepFailed, "detail2", []string{"err1"})
|
||||
|
||||
require.Len(t, report.Steps, 2)
|
||||
|
||||
assert.Equal(t, "step1", report.Steps[0].Name)
|
||||
assert.Equal(t, StepSuccess, report.Steps[0].Status)
|
||||
assert.Equal(t, "detail1", report.Steps[0].Detail)
|
||||
assert.Nil(t, report.Steps[0].Errors)
|
||||
|
||||
assert.Equal(t, "step2", report.Steps[1].Name)
|
||||
assert.Equal(t, StepFailed, report.Steps[1].Status)
|
||||
assert.Equal(t, []string{"err1"}, report.Steps[1].Errors)
|
||||
}
|
||||
|
||||
func TestPostDeletionReportAddStepWithParams(t *testing.T) {
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
params := map[string]any{"Count": 5}
|
||||
report.AddStepWithParams("step1", StepSuccess, "detail", params, nil)
|
||||
|
||||
require.Len(t, report.Steps, 1)
|
||||
assert.Equal(t, params, report.Steps[0].DetailParams)
|
||||
}
|
||||
|
||||
func TestPostDeletionReportCountStatuses(t *testing.T) {
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("s1", StepSuccess, "", nil)
|
||||
report.AddStep("s2", StepSuccess, "", nil)
|
||||
report.AddStep("s3", StepFailed, "", nil)
|
||||
report.AddStep("s4", StepPartial, "", nil)
|
||||
report.AddStep("s5", StepPartial, "", nil)
|
||||
report.AddStep("s6", StepNotApplicable, "", nil)
|
||||
report.AddStep("s7", StepNotApplicable, "", nil)
|
||||
report.AddStep("s8", StepNotApplicable, "", nil)
|
||||
|
||||
success, failed, partial, notApplicable := report.CountStatuses()
|
||||
assert.Equal(t, 2, success)
|
||||
assert.Equal(t, 1, failed)
|
||||
assert.Equal(t, 2, partial)
|
||||
assert.Equal(t, 3, notApplicable)
|
||||
}
|
||||
|
||||
func TestPostDeletionReportCountStatusesEmpty(t *testing.T) {
|
||||
report := &PostDeletionReport{}
|
||||
success, failed, partial, notApplicable := report.CountStatuses()
|
||||
assert.Equal(t, 0, success)
|
||||
assert.Equal(t, 0, failed)
|
||||
assert.Equal(t, 0, partial)
|
||||
assert.Equal(t, 0, notApplicable)
|
||||
}
|
||||
|
||||
func TestCountSubStepSuccesses(t *testing.T) {
|
||||
subSteps := []DeletionSubStep{
|
||||
{Name: "a", Status: StepSuccess},
|
||||
{Name: "b", Status: StepFailed},
|
||||
{Name: "c", Status: StepSuccess},
|
||||
{Name: "d", Status: StepPartial},
|
||||
}
|
||||
assert.Equal(t, 2, CountSubStepSuccesses(subSteps))
|
||||
}
|
||||
|
||||
func TestCountSubStepSuccessesEmpty(t *testing.T) {
|
||||
assert.Equal(t, 0, CountSubStepSuccesses(nil))
|
||||
assert.Equal(t, 0, CountSubStepSuccesses([]DeletionSubStep{}))
|
||||
}
|
||||
|
||||
func TestTranslateDetail(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{}
|
||||
|
||||
t.Run("empty detail returns empty string", func(t *testing.T) {
|
||||
assert.Equal(t, "", report.translateDetail(T, "", nil))
|
||||
})
|
||||
|
||||
t.Run("detail without params returns translation ID", func(t *testing.T) {
|
||||
assert.Equal(t, "some.key", report.translateDetail(T, "some.key", nil))
|
||||
})
|
||||
|
||||
t.Run("detail with params interpolates", func(t *testing.T) {
|
||||
result := report.translateDetail(T, "{{.Count}} rows deleted.", map[string]any{"Count": 3})
|
||||
assert.Equal(t, "3 rows deleted.", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderAllSuccess(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
ts := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
report := &PostDeletionReport{
|
||||
PostID: "test-post-id",
|
||||
Timestamp: ts,
|
||||
}
|
||||
|
||||
report.AddStep("app.data_spillage.report.step.priority_data", StepSuccess, "app.data_spillage.report.detail.deleted", nil)
|
||||
report.AddStep("app.data_spillage.report.step.reminders", StepSuccess, "app.data_spillage.report.detail.deleted", nil)
|
||||
|
||||
result := report.Render(T)
|
||||
|
||||
// Header
|
||||
assert.Contains(t, result, "### app.data_spillage.report.title")
|
||||
assert.Contains(t, result, "`test-post-id`")
|
||||
assert.Contains(t, result, "2025-01-15 at 10:30:00 UTC")
|
||||
|
||||
// Steps rendered with success icon
|
||||
assert.Contains(t, result, "✅ app.data_spillage.report.step.priority_data")
|
||||
assert.Contains(t, result, "✅ app.data_spillage.report.step.reminders")
|
||||
assert.Contains(t, result, "app.data_spillage.report.detail.deleted")
|
||||
|
||||
// Summary table
|
||||
assert.Contains(t, result, "📊 app.data_spillage.report.summary")
|
||||
assert.Contains(t, result, "|---|---|---|---|")
|
||||
|
||||
// No incomplete warning when all succeed
|
||||
assert.NotContains(t, result, "app.data_spillage.report.incomplete_warning")
|
||||
}
|
||||
|
||||
func TestRenderWithFailures(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post123",
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
report.AddStep("step.success", StepSuccess, "ok", nil)
|
||||
report.AddStep("step.failed", StepFailed, "", []string{"db connection lost"})
|
||||
|
||||
result := report.Render(T)
|
||||
|
||||
// Should contain the incomplete warning
|
||||
assert.Contains(t, result, "app.data_spillage.report.incomplete_warning")
|
||||
|
||||
// Error log for the failed step
|
||||
assert.Contains(t, result, "app.data_spillage.report.error_log")
|
||||
assert.Contains(t, result, "db connection lost")
|
||||
}
|
||||
|
||||
func TestRenderWithPartialStatus(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post456",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("step.partial", StepPartial, "some detail", nil)
|
||||
|
||||
result := report.Render(T)
|
||||
assert.Contains(t, result, "⚠️")
|
||||
assert.Contains(t, result, "app.data_spillage.report.incomplete_warning")
|
||||
}
|
||||
|
||||
func TestRenderWithSubSteps(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post789",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.Steps = append(report.Steps, DeletionStepResult{
|
||||
Name: "app.data_spillage.report.step.edit_histories",
|
||||
Status: StepPartial,
|
||||
Detail: "{{.Count}} of {{.Total}} revisions cleared",
|
||||
DetailParams: map[string]any{
|
||||
"Count": 2,
|
||||
"Total": 3,
|
||||
},
|
||||
SubSteps: []DeletionSubStep{
|
||||
{Name: "edit1", Status: StepSuccess},
|
||||
{Name: "edit2", Status: StepSuccess},
|
||||
{Name: "edit3", Status: StepFailed, Errors: []string{"timeout"}},
|
||||
},
|
||||
})
|
||||
|
||||
result := report.Render(T)
|
||||
|
||||
// Sub-step heading
|
||||
assert.Contains(t, result, "app.data_spillage.report.step.edit_histories")
|
||||
|
||||
// Revision counts
|
||||
assert.Contains(t, result, "app.data_spillage.report.revisions_found")
|
||||
|
||||
// Individual sub-steps
|
||||
assert.Contains(t, result, "`edit1`")
|
||||
assert.Contains(t, result, "`edit2`")
|
||||
assert.Contains(t, result, "`edit3`")
|
||||
|
||||
// Error from failed sub-step
|
||||
assert.Contains(t, result, "timeout")
|
||||
}
|
||||
|
||||
func TestRenderSummaryAllSuccess(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post-ok",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("step1", StepSuccess, "detail1", nil)
|
||||
report.AddStep("step2", StepSuccess, "detail2", nil)
|
||||
|
||||
result := report.RenderSummary(T)
|
||||
|
||||
// Summary table present
|
||||
assert.Contains(t, result, "📊 app.data_spillage.report.summary")
|
||||
assert.Contains(t, result, "step1")
|
||||
assert.Contains(t, result, "step2")
|
||||
|
||||
// No warning
|
||||
assert.NotContains(t, result, "app.data_spillage.report.incomplete_warning")
|
||||
}
|
||||
|
||||
func TestRenderSummaryWithFailure(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post-fail",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("step1", StepSuccess, "", nil)
|
||||
report.AddStep("step2", StepFailed, "", []string{"err"})
|
||||
|
||||
result := report.RenderSummary(T)
|
||||
|
||||
assert.Contains(t, result, "app.data_spillage.report.incomplete_warning")
|
||||
}
|
||||
|
||||
func TestRenderSummaryTableRowCount(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post-rows",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
for i := range 5 {
|
||||
report.AddStep(fmt.Sprintf("step%d", i+1), StepSuccess, "", nil)
|
||||
}
|
||||
|
||||
result := report.RenderSummary(T)
|
||||
|
||||
// Table header + separator + 5 data rows
|
||||
lines := strings.Split(strings.TrimSpace(result), "\n")
|
||||
tableLines := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "|") {
|
||||
tableLines++
|
||||
}
|
||||
}
|
||||
// header + separator + 5 rows = 7
|
||||
assert.Equal(t, 7, tableLines)
|
||||
}
|
||||
|
||||
func TestRenderErrorsMultiline(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post-err",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("step1", StepFailed, "", []string{"line1\nline2\nline3"})
|
||||
|
||||
result := report.Render(T)
|
||||
|
||||
assert.Contains(t, result, "> line1")
|
||||
assert.Contains(t, result, "> line2")
|
||||
assert.Contains(t, result, "> line3")
|
||||
}
|
||||
|
||||
func TestRenderEmptyReport(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "empty-post",
|
||||
Timestamp: time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
result := report.Render(T)
|
||||
|
||||
// Should still render header and empty summary table
|
||||
assert.Contains(t, result, "### app.data_spillage.report.title")
|
||||
assert.Contains(t, result, "`empty-post`")
|
||||
assert.Contains(t, result, "📊 app.data_spillage.report.summary")
|
||||
|
||||
// No warning since there are no failures
|
||||
assert.NotContains(t, result, "app.data_spillage.report.incomplete_warning")
|
||||
}
|
||||
|
||||
func TestRenderStepWithParamsDetail(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post-params",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStepWithParams(
|
||||
"app.data_spillage.report.step.fileinfo_rows",
|
||||
StepSuccess,
|
||||
"{{.Count}} rows deleted.",
|
||||
map[string]any{"Count": 7},
|
||||
nil,
|
||||
)
|
||||
|
||||
result := report.Render(T)
|
||||
assert.Contains(t, result, "7 rows deleted.")
|
||||
}
|
||||
|
||||
func TestRenderSubStepWithDetail(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "post-sub-detail",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.Steps = append(report.Steps, DeletionStepResult{
|
||||
Name: "edit_step",
|
||||
Status: StepSuccess,
|
||||
SubSteps: []DeletionSubStep{
|
||||
{
|
||||
Name: "rev1",
|
||||
Status: StepSuccess,
|
||||
Detail: "**File Attachments:** {{.FileNames}} — **FileInfo Rows:** {{.Count}} deleted",
|
||||
DetailParams: map[string]any{"FileNames": "`doc.pdf`", "Count": 1},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := report.Render(T)
|
||||
assert.Contains(t, result, "`doc.pdf`")
|
||||
assert.Contains(t, result, "1 deleted")
|
||||
}
|
||||
|
||||
func TestRenderSummaryTableStatusIcons(t *testing.T) {
|
||||
T := stubTranslateFunc
|
||||
report := &PostDeletionReport{
|
||||
PostID: "icons",
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
report.AddStep("s1", StepSuccess, "", nil)
|
||||
report.AddStep("s2", StepFailed, "", nil)
|
||||
report.AddStep("s3", StepPartial, "", nil)
|
||||
|
||||
result := report.RenderSummary(T)
|
||||
|
||||
// Each row should have the correct icon
|
||||
lines := strings.Split(result, "\n")
|
||||
var dataRows []string
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "|") && !strings.HasPrefix(line, "|---") && !strings.Contains(line, "app.data_spillage.report.column") {
|
||||
dataRows = append(dataRows, line)
|
||||
}
|
||||
}
|
||||
require.Len(t, dataRows, 3)
|
||||
assert.Contains(t, dataRows[0], "✅")
|
||||
assert.Contains(t, dataRows[1], "❌")
|
||||
assert.Contains(t, dataRows[2], "⚠️")
|
||||
}
|
||||
@@ -49,6 +49,11 @@ var T TranslateFunc = func(translationID string, args ...any) string {
|
||||
return t(translationID, args...)
|
||||
}
|
||||
|
||||
// TranslationId is a no-op translation implementation for ensuring the string is retained in translation files
|
||||
var TranslationId TranslateFunc = func(translationID string, args ...any) string {
|
||||
return translationID
|
||||
}
|
||||
|
||||
// TDefault is the translate function using english as fallback language
|
||||
var TDefault TranslateFunc = func(translationID string, args ...any) string {
|
||||
mut.Lock()
|
||||
|
||||
@@ -378,6 +378,16 @@ func extractByFuncName(name string, args []ast.Expr) *string {
|
||||
return nil
|
||||
}
|
||||
|
||||
key, ok := args[0].(*ast.BasicLit)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &key.Value
|
||||
} else if name == "TranslationId" {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
key, ok := args[0].(*ast.BasicLit)
|
||||
if !ok {
|
||||
return nil
|
||||
|
||||
+12
-4
@@ -28,11 +28,16 @@ describe('DataSpillageFooter', () => {
|
||||
const post = TestHelper.getPostMock();
|
||||
|
||||
renderWithContext(
|
||||
<DataSpillageFooter post={post}/>,
|
||||
<DataSpillageFooter
|
||||
post={post}
|
||||
flaggedPostID='flaggedPostID'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('data-spillage-footer')).toBeVisible();
|
||||
expect(screen.getByTestId('data-spillage-action-view-details')).toBeVisible();
|
||||
expect(
|
||||
screen.getByTestId('data-spillage-action-view-details_flaggedPostID'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('View details')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -43,10 +48,13 @@ describe('DataSpillageFooter', () => {
|
||||
});
|
||||
|
||||
renderWithContext(
|
||||
<DataSpillageFooter post={post}/>,
|
||||
<DataSpillageFooter
|
||||
post={post}
|
||||
flaggedPostID='flaggedPostID'
|
||||
/>,
|
||||
);
|
||||
|
||||
const viewDetailsButton = screen.getByTestId('data-spillage-action-view-details');
|
||||
const viewDetailsButton = screen.getByTestId('data-spillage-action-view-details_flaggedPostID');
|
||||
await userEvent.click(viewDetailsButton);
|
||||
|
||||
expect(mockedSelectPostFromRightHandSideSearch).toHaveBeenCalledTimes(1);
|
||||
|
||||
+4
-3
@@ -11,9 +11,10 @@ import {selectPostFromRightHandSideSearch} from 'actions/views/rhs';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
}
|
||||
flaggedPostID: string;
|
||||
};
|
||||
|
||||
export default function DataSpillageFooter({post}: Props) {
|
||||
export default function DataSpillageFooter({post, flaggedPostID}: Props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
@@ -29,7 +30,7 @@ export default function DataSpillageFooter({post}: Props) {
|
||||
>
|
||||
<button
|
||||
className='btn btn-primary btn-sm'
|
||||
data-testid='data-spillage-action-view-details'
|
||||
data-testid={`data-spillage-action-view-details_${flaggedPostID}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
|
||||
+7
-2
@@ -149,8 +149,13 @@ export function DataSpillageReport({post, isRHS}: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<DataSpillageFooter post={post}/>);
|
||||
}, [isRHS, post]);
|
||||
return (
|
||||
<DataSpillageFooter
|
||||
post={post}
|
||||
flaggedPostID={reportedPostId}
|
||||
/>
|
||||
);
|
||||
}, [isRHS, post, reportedPostId]);
|
||||
|
||||
const actionRow = useMemo(() => {
|
||||
if (!reportedPost || !reportingUser) {
|
||||
|
||||
Reference in New Issue
Block a user