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:
Harshil Sharma
2026-05-04 06:40:26 +05:30
committed by GitHub
parent ace28cd516
commit d4f147e2da
23 changed files with 1742 additions and 82 deletions
@@ -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}`);
}
}
@@ -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);
});
+211 -43
View File
@@ -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))
+240 -4
View File
@@ -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
View File
@@ -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.
+59 -3
View File
@@ -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) {
+16 -8
View File
@@ -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
+87
View File
@@ -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 {
+1
View File
@@ -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()
+162
View File
@@ -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."
+245
View File
@@ -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 &nbsp;|&nbsp; ✅ %s: %d &nbsp;|&nbsp; %s: %d &nbsp;|&nbsp; ⚠️ %s: %d &nbsp;|&nbsp; ❌ %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 &nbsp;|&nbsp; ✅ %s: %d &nbsp;|&nbsp; ❌ %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], "⚠️")
}
+5
View File
@@ -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()
+10
View File
@@ -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
@@ -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);
@@ -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
@@ -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) {