Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
775 lines
28 KiB
Go
775 lines
28 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"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"
|
|
)
|
|
|
|
// readReportZip opens the ZIP archive at path and returns a map of entry name
|
|
// to contents. The temp file produced by GenerateFlaggedPostReport is also
|
|
// removed from disk after reading so individual tests don't have to clean up.
|
|
func readReportZip(t *testing.T, path string) map[string][]byte {
|
|
t.Helper()
|
|
defer func() {
|
|
_ = os.Remove(path)
|
|
}()
|
|
|
|
zr, err := zip.OpenReader(path)
|
|
require.NoError(t, err)
|
|
defer zr.Close()
|
|
|
|
out := map[string][]byte{}
|
|
for _, f := range zr.File {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
b, err := io.ReadAll(rc)
|
|
require.NoError(t, err)
|
|
_ = rc.Close()
|
|
out[f.Name] = b
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestGenerateFlaggedPostReport(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("returns error when post does not exist", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "", "", true)
|
|
require.NotNil(t, appErr)
|
|
require.Empty(t, path)
|
|
})
|
|
|
|
t.Run("returns error when generator user does not exist", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "", "", true)
|
|
require.NotNil(t, appErr)
|
|
require.Empty(t, path)
|
|
})
|
|
|
|
t.Run("produces a zip with the expected entries for a basic flagged post", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
require.NotEmpty(t, path)
|
|
|
|
_, statErr := os.Stat(path)
|
|
require.NoError(t, statErr, "report temp file should exist on disk until caller removes it")
|
|
|
|
entries := readReportZip(t, path)
|
|
require.Contains(t, entries, "post/post.yaml")
|
|
require.Contains(t, entries, "content_review.yaml")
|
|
require.Contains(t, entries, "report_metadata.yaml")
|
|
require.Contains(t, entries, "exposure_report.csv")
|
|
})
|
|
|
|
t.Run("exposure_report.csv is a parseable exposure report", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
seedOldChannelMemberHistory(t, th)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
require.Contains(t, entries, "exposure_report.csv")
|
|
|
|
body := string(entries["exposure_report.csv"])
|
|
require.Contains(t, body, "# Post ID: "+post.Id)
|
|
|
|
r := csv.NewReader(bytes.NewReader(entries["exposure_report.csv"]))
|
|
r.Comment = '#'
|
|
records, err := r.ReadAll()
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, records)
|
|
require.Equal(t, model.PostExposureReportCSVHeader(i18n.GetUserTranslations("en")), records[0])
|
|
|
|
var found bool
|
|
for _, record := range records[1:] {
|
|
if record[0] == th.BasicUser.Id {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found)
|
|
})
|
|
|
|
t.Run("post.yaml contains channel, team, and author details", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var payload map[string]any
|
|
require.NoError(t, yaml.Unmarshal(entries["post/post.yaml"], &payload))
|
|
require.Equal(t, post.Id, payload["id"])
|
|
require.Equal(t, post.UserId, payload["author_id"])
|
|
require.Equal(t, th.BasicUser.Username, payload["author_name"])
|
|
require.Equal(t, th.BasicChannel.DisplayName, payload["channel_display_name"])
|
|
require.Equal(t, th.BasicTeam.Id, payload["team_id"])
|
|
require.Equal(t, th.BasicTeam.DisplayName, payload["team_display_name"])
|
|
})
|
|
|
|
t.Run("report_metadata.yaml records the generating user and a non-zero timestamp", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var meta model.FlaggedPostReportMetadata
|
|
require.NoError(t, yaml.Unmarshal(entries["report_metadata.yaml"], &meta))
|
|
require.Equal(t, th.BasicUser.Id, meta.GeneratedByUserID)
|
|
require.Equal(t, th.BasicUser.Username, meta.GeneratedByUsername)
|
|
require.Equal(t, model.FlaggedPostReportVersion, meta.ReportVersion)
|
|
require.Greater(t, meta.Timestamp, int64(0))
|
|
})
|
|
|
|
t.Run("content_review.yaml records reporter details", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var review model.FlaggedPostReportContentReview
|
|
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
|
|
// setupFlaggedPost flags as BasicUser2 with reason "spam".
|
|
require.Equal(t, th.BasicUser2.Id, review.ReporterUserID)
|
|
require.Equal(t, th.BasicUser2.Username, review.ReporterUsername)
|
|
require.Equal(t, "spam", review.ReporterReason)
|
|
require.Equal(t, "This is spam content", review.ReporterComment)
|
|
require.Greater(t, review.ReportTimestamp, int64(0))
|
|
require.Empty(t, review.ActorDecision)
|
|
require.Empty(t, review.ActorUserId)
|
|
require.Empty(t, review.ActorUsername)
|
|
})
|
|
|
|
t.Run("content_review.yaml records remove decision and committed actor after permanent delete", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
appErr = th.App.PermanentDeleteFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "violates policy"}, th.SystemAdminUser.Id, post)
|
|
require.Nil(t, appErr)
|
|
|
|
// Report is generated by BasicUser, but the committed actor on the post is SystemAdminUser.
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var review model.FlaggedPostReportContentReview
|
|
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
|
|
require.Equal(t, "remove", review.ActorDecision)
|
|
require.Equal(t, th.SystemAdminUser.Id, review.ActorUserId)
|
|
require.Equal(t, th.SystemAdminUser.Username, review.ActorUsername)
|
|
})
|
|
|
|
t.Run("content_review.yaml records keep decision and committed actor after keep action", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
appErr = th.App.KeepFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "looks fine"}, th.SystemAdminUser.Id, post)
|
|
require.Nil(t, appErr)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var review model.FlaggedPostReportContentReview
|
|
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
|
|
require.Equal(t, "keep", review.ActorDecision)
|
|
require.Equal(t, th.SystemAdminUser.Id, review.ActorUserId)
|
|
require.Equal(t, th.SystemAdminUser.Username, review.ActorUsername)
|
|
})
|
|
|
|
t.Run("content_review.yaml prefers committed actor over pending action's generator", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
// Commit a keep action so the actor user id property is set to SystemAdminUser.
|
|
appErr = th.App.KeepFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "looks fine"}, th.SystemAdminUser.Id, post)
|
|
require.Nil(t, appErr)
|
|
|
|
// Even with a pending action supplied (and a different generator), the
|
|
// committed actor on the post should win over the pending-action fallback.
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", model.ContentFlaggingActionRemove, true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var review model.FlaggedPostReportContentReview
|
|
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
|
|
require.Equal(t, th.SystemAdminUser.Id, review.ActorUserId)
|
|
require.Equal(t, th.SystemAdminUser.Username, review.ActorUsername)
|
|
})
|
|
|
|
t.Run("content_review.yaml uses pending action when status is not yet committed", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", model.ContentFlaggingActionRemove, true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var review model.FlaggedPostReportContentReview
|
|
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
|
|
require.Equal(t, "remove", review.ActorDecision)
|
|
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
|
|
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
|
|
})
|
|
|
|
t.Run("content_review.yaml ignores invalid pending action", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "bogus", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
var review model.FlaggedPostReportContentReview
|
|
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
|
|
require.Empty(t, review.ActorDecision)
|
|
// Actor details are still populated whenever a pending action is supplied,
|
|
// even when the value isn't a recognised decision.
|
|
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
|
|
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
|
|
})
|
|
|
|
t.Run("includes file attachments for the base post", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := th.CreatePost(t, th.BasicChannel)
|
|
|
|
attachmentBody := []byte("hello attachment body")
|
|
fileInfo := &model.FileInfo{
|
|
Id: model.NewId(),
|
|
PostId: post.Id,
|
|
CreatorId: post.UserId,
|
|
Path: "test/" + model.NewId() + "/attachment.txt",
|
|
Name: "attachment.txt",
|
|
Size: int64(len(attachmentBody)),
|
|
}
|
|
_, appErr = th.App.WriteFile(bytes.NewReader(attachmentBody), fileInfo.Path)
|
|
require.Nil(t, appErr)
|
|
t.Cleanup(func() { _ = th.App.RemoveFile(fileInfo.Path) })
|
|
|
|
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Persist FileIds directly via the store. UpdatePost would route through
|
|
// AttachToPost, which is a no-op here because the FileInfo row already has
|
|
// PostId set, leaving post.FileIds empty in the DB.
|
|
post.FileIds = []string{fileInfo.Id}
|
|
_, err = th.App.Srv().Store().Post().Overwrite(th.Context, post)
|
|
require.NoError(t, err)
|
|
|
|
flagData := model.FlagContentRequest{
|
|
Reason: "spam",
|
|
Comment: "This is spam content",
|
|
}
|
|
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
|
require.Nil(t, appErr)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
entryName := "post/attachments/" + fileInfo.Id + "_" + fileInfo.Name
|
|
require.Contains(t, entries, entryName)
|
|
require.Equal(t, attachmentBody, entries[entryName])
|
|
require.Contains(t, entries, "post/post.yaml")
|
|
require.Contains(t, entries, "content_review.yaml")
|
|
require.Contains(t, entries, "report_metadata.yaml")
|
|
})
|
|
|
|
t.Run("includes edit history entries when the post has been edited", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := th.CreatePost(t, th.BasicChannel)
|
|
|
|
edited := post.Clone()
|
|
edited.Message = "Edited message"
|
|
edited.EditAt = model.GetMillis()
|
|
_, _, appErr = th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
|
|
require.Nil(t, appErr)
|
|
|
|
editHistory, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
|
|
require.Nil(t, appErr)
|
|
require.NotEmpty(t, editHistory)
|
|
editID := editHistory[0].Id
|
|
|
|
flagData := model.FlagContentRequest{
|
|
Reason: "spam",
|
|
Comment: "This is spam content",
|
|
}
|
|
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
|
|
require.Nil(t, appErr)
|
|
|
|
path, _, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
|
|
entries := readReportZip(t, path)
|
|
require.Contains(t, entries, "edit_history/"+editID+"/post.yaml")
|
|
|
|
// The base post.yaml should also list the edit history order.
|
|
var basePayload map[string]any
|
|
require.NoError(t, yaml.Unmarshal(entries["post/post.yaml"], &basePayload))
|
|
order, ok := basePayload["edit_history_order"].([]any)
|
|
require.True(t, ok, "edit_history_order should be present on the base post payload")
|
|
require.Contains(t, order, editID)
|
|
})
|
|
}
|
|
|
|
// attachFileToPost persists FileIds with Overwrite rather than UpdatePost, which would
|
|
// route through AttachToPost and no-op because the FileInfo row already carries PostId.
|
|
func attachFileToPost(t *testing.T, th *TestHelper, post *model.Post, name string, body []byte) *model.FileInfo {
|
|
t.Helper()
|
|
|
|
fileInfo := &model.FileInfo{
|
|
Id: model.NewId(),
|
|
PostId: post.Id,
|
|
CreatorId: post.UserId,
|
|
Path: "test/" + model.NewId() + "/" + name,
|
|
Name: name,
|
|
Size: int64(len(body)),
|
|
}
|
|
|
|
_, appErr := th.App.WriteFile(bytes.NewReader(body), fileInfo.Path)
|
|
require.Nil(t, appErr)
|
|
t.Cleanup(func() { _ = th.App.RemoveFile(fileInfo.Path) })
|
|
|
|
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
post.FileIds = append(post.FileIds, fileInfo.Id)
|
|
_, err = th.App.Srv().Store().Post().Overwrite(th.Context, post)
|
|
require.NoError(t, err)
|
|
|
|
return fileInfo
|
|
}
|
|
|
|
func attachmentEntries(entries map[string][]byte) []string {
|
|
out := []string{}
|
|
for name := range entries {
|
|
if strings.Contains(name, "/"+flaggedPostReportAttachmentsDir+"/") {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func flagPostForReport(t *testing.T, th *TestHelper, post *model.Post) {
|
|
t.Helper()
|
|
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, model.FlagContentRequest{
|
|
Reason: "spam",
|
|
Comment: "This is spam content",
|
|
})
|
|
require.Nil(t, appErr)
|
|
}
|
|
|
|
func TestGenerateFlaggedPostReportAttachmentPolicy(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("includes attachments and writes no marker when the caller allows the download", func(t *testing.T) {
|
|
require.Nil(t, setBaseConfig(th))
|
|
|
|
post := th.CreatePost(t, th.BasicChannel)
|
|
fileInfo := attachFileToPost(t, th, post, "allowed.txt", []byte("allowed attachment body"))
|
|
flagPostForReport(t, th, post)
|
|
|
|
path, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, 0, omitted)
|
|
|
|
entries := readReportZip(t, path)
|
|
entry := "post/attachments/" + fileInfo.Id + "_" + fileInfo.Name
|
|
require.Contains(t, entries, entry)
|
|
require.Equal(t, []byte("allowed attachment body"), entries[entry])
|
|
require.NotContains(t, entries, flaggedPostReportAttachmentsOmittedFile)
|
|
})
|
|
|
|
t.Run("omits attachments and writes the marker when the caller denies the download", func(t *testing.T) {
|
|
require.Nil(t, setBaseConfig(th))
|
|
|
|
post := th.CreatePost(t, th.BasicChannel)
|
|
fileInfo := attachFileToPost(t, th, post, "denied.txt", []byte("denied attachment body"))
|
|
flagPostForReport(t, th, post)
|
|
|
|
path, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", false)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, 1, omitted)
|
|
|
|
entries := readReportZip(t, path)
|
|
require.NotContains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
|
|
require.Empty(t, attachmentEntries(entries))
|
|
require.Contains(t, entries, "post/post.yaml")
|
|
require.Contains(t, entries, "content_review.yaml")
|
|
require.Contains(t, entries, "report_metadata.yaml")
|
|
require.Contains(t, entries, "exposure_report.csv")
|
|
|
|
require.Contains(t, entries, flaggedPostReportAttachmentsOmittedFile)
|
|
require.Equal(t,
|
|
i18n.GetUserTranslations("en")("app.data_spillage.report.attachments_omitted.file_text"),
|
|
string(entries[flaggedPostReportAttachmentsOmittedFile]),
|
|
)
|
|
})
|
|
|
|
t.Run("strips attachment metadata from the edit history payload when denied", func(t *testing.T) {
|
|
require.Nil(t, setBaseConfig(th))
|
|
|
|
post := th.CreatePost(t, th.BasicChannel)
|
|
fileInfo := attachFileToPost(t, th, post, "edited.txt", []byte("edited attachment body"))
|
|
|
|
edited := post.Clone()
|
|
edited.Message = "Edited message"
|
|
edited.EditAt = model.GetMillis()
|
|
_, _, appErr := th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
|
|
require.Nil(t, appErr)
|
|
|
|
editHistory, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
|
|
require.Nil(t, appErr)
|
|
require.NotEmpty(t, editHistory)
|
|
editID := editHistory[0].Id
|
|
require.NotEmpty(t, editHistory[0].Metadata.Files, "precondition: the edit history entry should carry file metadata")
|
|
|
|
flagPostForReport(t, th, post)
|
|
|
|
allowedPath, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", true)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, 0, omitted)
|
|
|
|
allowed := readReportZip(t, allowedPath)
|
|
require.Contains(t, string(allowed["edit_history/"+editID+"/post.yaml"]), fileInfo.Id,
|
|
"precondition: the edit history payload leaks file metadata when the download is allowed")
|
|
|
|
deniedPath, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", false)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, 1, omitted, "an attachment shared by the base post and an edit history entry counts once")
|
|
|
|
denied := readReportZip(t, deniedPath)
|
|
require.Contains(t, denied, "edit_history/"+editID+"/post.yaml")
|
|
require.NotContains(t, string(denied["edit_history/"+editID+"/post.yaml"]), fileInfo.Id)
|
|
require.NotContains(t, string(denied["edit_history/"+editID+"/post.yaml"]), fileInfo.Name)
|
|
require.NotContains(t, string(denied["post/post.yaml"]), fileInfo.Id)
|
|
require.Empty(t, attachmentEntries(denied))
|
|
})
|
|
|
|
t.Run("writes no marker when a denied post has no attachments to omit", func(t *testing.T) {
|
|
require.Nil(t, setBaseConfig(th))
|
|
|
|
post := th.CreatePost(t, th.BasicChannel)
|
|
flagPostForReport(t, th, post)
|
|
|
|
path, omitted, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "", false)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, 0, omitted)
|
|
|
|
entries := readReportZip(t, path)
|
|
require.NotContains(t, entries, flaggedPostReportAttachmentsOmittedFile)
|
|
require.Contains(t, entries, "post/post.yaml")
|
|
})
|
|
}
|
|
|
|
// TestBuildContentReviewYAMLActorCommentFallback exercises the replica-lag
|
|
// fallback in buildContentReviewYAML: when the property-value read (which is
|
|
// served from a read replica) returns no actor_comment value, the function
|
|
// must fall back to the in-hand actorComment supplied by the caller so the
|
|
// generated report still reflects the comment the user just submitted.
|
|
func TestBuildContentReviewYAMLActorCommentFallback(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("falls back to in-hand comment when no actor_comment is persisted", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
// setupFlaggedPost only writes the reporting_* properties — no
|
|
// actor_comment row exists yet, so the replica read returns "" for
|
|
// that field and the fallback branch must take effect.
|
|
review, appErr := th.App.buildContentReviewYAML(th.Context, post, th.BasicUser.Id, "fallback note", "")
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, "fallback note", review.ReviewerComment)
|
|
})
|
|
|
|
t.Run("reviewer comment stays empty when both sources are empty", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
review, appErr := th.App.buildContentReviewYAML(th.Context, post, th.BasicUser.Id, "", "")
|
|
require.Nil(t, appErr)
|
|
require.Empty(t, review.ReviewerComment)
|
|
})
|
|
}
|
|
|
|
func TestBuildPostYAML(t *testing.T) {
|
|
channel := &model.Channel{Id: "channel-id", DisplayName: "Channel Name"}
|
|
team := &model.Team{Id: "team-id", DisplayName: "Team Name"}
|
|
author := &model.User{Id: "author-id", Username: "alice", Email: "alice@example.com"}
|
|
|
|
t.Run("populates author, channel, and team fields", func(t *testing.T) {
|
|
post := &model.Post{Id: "post-id", UserId: author.Id, ChannelId: channel.Id, Message: "hi"}
|
|
|
|
got := buildPostYAML(post, channel, team, author, nil)
|
|
|
|
require.Equal(t, "alice", got.AuthorName)
|
|
require.Equal(t, "alice@example.com", got.AuthorEmail)
|
|
require.Equal(t, "Channel Name", got.ChannelDisplayName)
|
|
require.Equal(t, "team-id", got.TeamID)
|
|
require.Equal(t, "Team Name", got.TeamDisplayName)
|
|
})
|
|
|
|
t.Run("omits team fields for DM/GM posts", func(t *testing.T) {
|
|
post := &model.Post{Id: "post-id", UserId: author.Id, ChannelId: channel.Id}
|
|
|
|
got := buildPostYAML(post, channel, nil, author, nil)
|
|
|
|
require.Empty(t, got.TeamID)
|
|
require.Empty(t, got.TeamDisplayName)
|
|
})
|
|
|
|
t.Run("omits author fields when author is nil", func(t *testing.T) {
|
|
post := &model.Post{Id: "post-id", ChannelId: channel.Id}
|
|
|
|
got := buildPostYAML(post, channel, team, nil, nil)
|
|
|
|
require.Empty(t, got.AuthorName)
|
|
require.Empty(t, got.AuthorEmail)
|
|
})
|
|
|
|
t.Run("populates reply count only for root posts", func(t *testing.T) {
|
|
root := &model.Post{Id: "root", ChannelId: channel.Id, ReplyCount: 3}
|
|
got := buildPostYAML(root, channel, team, author, nil)
|
|
require.NotNil(t, got.ReplyCountPtr)
|
|
require.Equal(t, int64(3), *got.ReplyCountPtr)
|
|
|
|
reply := &model.Post{Id: "reply", ChannelId: channel.Id, RootId: "root", ReplyCount: 0}
|
|
got = buildPostYAML(reply, channel, team, author, nil)
|
|
require.Nil(t, got.ReplyCountPtr)
|
|
})
|
|
|
|
t.Run("preserves edit history order verbatim", func(t *testing.T) {
|
|
post := &model.Post{Id: "post-id", ChannelId: channel.Id}
|
|
order := []string{"edit-3", "edit-2", "edit-1"}
|
|
|
|
got := buildPostYAML(post, channel, team, author, order)
|
|
|
|
require.Equal(t, order, got.EditHistoryOrder)
|
|
})
|
|
}
|
|
|
|
func TestAttachmentEntryName(t *testing.T) {
|
|
t.Run("prefixes the file id and keeps the original name", func(t *testing.T) {
|
|
fi := &model.FileInfo{Id: "abc123", Name: "report.pdf"}
|
|
require.Equal(t, "abc123_report.pdf", attachmentEntryName(fi))
|
|
})
|
|
|
|
t.Run("strips forward and back slashes to prevent path traversal", func(t *testing.T) {
|
|
fi := &model.FileInfo{Id: "abc123", Name: "../../etc/passwd"}
|
|
require.Equal(t, "abc123_.._.._etc_passwd", attachmentEntryName(fi))
|
|
|
|
fi = &model.FileInfo{Id: "abc123", Name: `..\..\windows\system32`}
|
|
require.Equal(t, "abc123_.._.._windows_system32", attachmentEntryName(fi))
|
|
})
|
|
|
|
t.Run("falls back to a synthesised name when the original is empty", func(t *testing.T) {
|
|
fi := &model.FileInfo{Id: "abc123", Name: "", Extension: "pdf"}
|
|
require.Equal(t, "abc123_attachment.pdf", attachmentEntryName(fi))
|
|
|
|
fi = &model.FileInfo{Id: "abc123", Name: " ", Extension: ""}
|
|
require.Equal(t, "abc123_attachment", attachmentEntryName(fi))
|
|
})
|
|
}
|
|
|
|
func TestDecodePropertyString(t *testing.T) {
|
|
rctx := request.EmptyContext(mlog.CreateConsoleTestLogger(t))
|
|
|
|
t.Run("returns empty string when key is missing", func(t *testing.T) {
|
|
got := decodePropertyString(rctx, map[string]json.RawMessage{}, "missing")
|
|
require.Empty(t, got)
|
|
})
|
|
|
|
t.Run("returns empty string for empty raw value", func(t *testing.T) {
|
|
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": []byte{}}, "k")
|
|
require.Empty(t, got)
|
|
})
|
|
|
|
t.Run("decodes a JSON-encoded string", func(t *testing.T) {
|
|
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": json.RawMessage(`"hello \"world\""`)}, "k")
|
|
require.Equal(t, `hello "world"`, got)
|
|
})
|
|
|
|
t.Run("returns empty string when the raw value is not valid JSON", func(t *testing.T) {
|
|
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": json.RawMessage("not-json")}, "k")
|
|
require.Empty(t, got)
|
|
})
|
|
}
|
|
|
|
func TestDecodePropertyInt64(t *testing.T) {
|
|
rctx := request.EmptyContext(mlog.CreateConsoleTestLogger(t))
|
|
|
|
t.Run("returns zero when key is missing", func(t *testing.T) {
|
|
got := decodePropertyInt64(rctx, map[string]json.RawMessage{}, "missing")
|
|
require.Equal(t, int64(0), got)
|
|
})
|
|
|
|
t.Run("returns zero for empty raw value", func(t *testing.T) {
|
|
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": []byte{}}, "k")
|
|
require.Equal(t, int64(0), got)
|
|
})
|
|
|
|
t.Run("decodes a JSON-encoded number", func(t *testing.T) {
|
|
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": json.RawMessage("12345")}, "k")
|
|
require.Equal(t, int64(12345), got)
|
|
})
|
|
|
|
t.Run("returns zero when the raw value is not a JSON number", func(t *testing.T) {
|
|
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": json.RawMessage(`"12345"`)}, "k")
|
|
require.Equal(t, int64(0), got)
|
|
})
|
|
}
|
|
|
|
func TestNotifyReviewersOfFlaggedPostReportGeneration(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("does not panic when called for a non-flagged post", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
// Use a random post id to exercise the best-effort error path. The function
|
|
// should swallow any errors and return without raising.
|
|
require.NotPanics(t, func() {
|
|
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, model.NewId(), th.BasicUser.Id)
|
|
})
|
|
})
|
|
|
|
t.Run("does not panic when generator user does not exist", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
require.NotPanics(t, func() {
|
|
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, post.Id, model.NewId())
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestNotifyReviewersOfPostExposureReportGeneration(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
reviewerMessages := func(t *testing.T, postID string) []string {
|
|
t.Helper()
|
|
|
|
groupID, appErr := th.App.ContentFlaggingGroupId()
|
|
require.Nil(t, appErr)
|
|
|
|
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(th.Context, groupID)
|
|
require.Nil(t, appErr)
|
|
|
|
rootPostIDs, appErr := th.App.getReviewerPostsForFlaggedPost(th.Context, groupID, postID, mappedFields[contentFlaggingPropertyNameFlaggedPostId].ID)
|
|
require.Nil(t, appErr)
|
|
require.NotEmpty(t, rootPostIDs)
|
|
|
|
var messages []string
|
|
for _, rootPostID := range rootPostIDs {
|
|
thread, appErr := th.App.GetPostThread(th.Context, rootPostID, model.GetPostsOptions{}, "")
|
|
require.Nil(t, appErr)
|
|
for _, reply := range thread.Posts {
|
|
messages = append(messages, reply.Message)
|
|
}
|
|
}
|
|
return messages
|
|
}
|
|
|
|
t.Run("posts an exposure-specific message distinct from the flagged post report message", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
th.App.NotifyReviewersOfPostExposureReportGeneration(th.Context, post.Id, th.BasicUser.Id)
|
|
|
|
messages := reviewerMessages(t, post.Id)
|
|
require.Contains(t, messages, "@"+th.BasicUser.Username+" downloaded an exposure report for the quarantined message.")
|
|
require.NotContains(t, messages, "@"+th.BasicUser.Username+" generated a report for the quarantined message.")
|
|
})
|
|
|
|
t.Run("leaves the flagged post report wording unchanged", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
post := setupFlaggedPost(t, th)
|
|
|
|
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, post.Id, th.BasicUser.Id)
|
|
|
|
messages := reviewerMessages(t, post.Id)
|
|
require.Contains(t, messages, "@"+th.BasicUser.Username+" generated a report for the quarantined message.")
|
|
})
|
|
|
|
t.Run("does not panic for a non-flagged post", func(t *testing.T) {
|
|
appErr := setBaseConfig(th)
|
|
require.Nil(t, appErr)
|
|
|
|
require.NotPanics(t, func() {
|
|
th.App.NotifyReviewersOfPostExposureReportGeneration(th.Context, model.NewId(), th.BasicUser.Id)
|
|
})
|
|
})
|
|
}
|