Files
upstream-mattermost/server/channels/app/content_flagging_report_test.go
upstream-mirror 240db4e2a7
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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

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