snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,695 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"archive/zip"
"bytes"
"context"
"encoding/csv"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/goccy/go-yaml"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/require"
)
func TestGenerateFlaggedPostReport(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 400 when post ID is invalid", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), "invalid", &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
appErr := setNonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should successfully generate report for a flagged post", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
entries := map[string]bool{}
for _, f := range zr.File {
entries[f.Name] = true
}
require.Contains(t, entries, "report_metadata.yaml")
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
})
t.Run("Should successfully generate report when user is a team reviewer", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should include file attachments in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var foundAttachment bool
for _, f := range zr.File {
if f.Name == "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name {
foundAttachment = true
break
}
}
require.True(t, foundAttachment, "attachment for the flagged post should be present in the report archive")
})
t.Run("Should include reviewer decision from request action", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{
Comment: "investigation note",
Action: model.ContentFlaggingActionRemove,
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var review model.FlaggedPostReportContentReview
var found bool
for _, f := range zr.File {
if f.Name != "content_review.yaml" {
continue
}
rc, err := f.Open()
require.NoError(t, err)
b, err := io.ReadAll(rc)
require.NoError(t, err)
_ = rc.Close()
require.NoError(t, yaml.Unmarshal(b, &review))
found = true
break
}
require.True(t, found, "content_review.yaml should be present in the report archive")
require.Equal(t, "remove", review.ActorDecision)
require.Equal(t, th.BasicUser.Id, review.ActorUserId)
require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("Should include edit history entries in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
post.Message = "Updated message to create edit history"
_, _, err := client.UpdatePost(context.Background(), post.Id, post)
require.NoError(t, err)
editHistory, appErr := th.App.GetEditHistoryForPost(th.Context, post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistory)
editId := editHistory[0].Id
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var foundEdit bool
for _, f := range zr.File {
if f.Name == "edit_history/"+editId+"/post.yaml" {
foundEdit = true
break
}
}
require.True(t, foundEdit, "edit history entry should be present in the report archive")
})
t.Run("Should not allow generating a report for a post in a DM channel", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := createDmPost(t, th, client)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.data_spillage.error.invalid_channel_type")
require.Empty(t, report)
})
t.Run("Should not allow generating a report for a post in a GM channel", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := createGmPost(t, th, client)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.data_spillage.error.invalid_channel_type")
require.Empty(t, report)
})
}
// parseExposureCSV skips the "#"-prefixed metadata preamble and returns the remaining records.
func parseExposureCSV(t *testing.T, b []byte) [][]string {
t.Helper()
r := csv.NewReader(bytes.NewReader(b))
r.Comment = '#'
records, err := r.ReadAll()
require.NoError(t, err)
return records
}
func TestGeneratePostExposureReport(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost(t)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 400 when post ID is invalid", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
report, resp, err := client.GeneratePostExposureReport(context.Background(), "invalid")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
appErr := setNonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should successfully generate report when the post has already been retained", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
resp, err := client.KeepFlaggedPost(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "looks fine"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
require.Contains(t, resp.Header.Get("Content-Type"), "text/csv")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id)
})
t.Run("Should successfully generate report when the post has already been removed", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
resp, err := client.RemoveFlaggedPost(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "confirmed spillage"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Removal scrubs the post's content but retains a stub row, and it never deletes
// the reporting_time property, so the exposure window is still computable.
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
require.Contains(t, resp.Header.Get("Content-Type"), "text/csv")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id)
})
t.Run("Should successfully generate report for a common reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
require.Contains(t, resp.Header.Get("Content-Type"), "text/csv")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment; filename=\"post-exposure-"+post.Id)
})
t.Run("Should successfully generate report when user is a team reviewer", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should generate report for both the assignee and a non-assignee reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th, th.BasicUser2.Id)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser2.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// BasicUser is a reviewer but not the assignee.
report, resp, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
// BasicUser2 is the assignee, and is therefore also a reviewer.
assigneeClient := th.CreateClient()
th.LoginBasic2WithClient(t, assigneeClient)
report, resp, err = assigneeClient.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should return a parseable CSV listing the channel members", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, _, err := client.GeneratePostExposureReport(context.Background(), post.Id)
require.NoError(t, err)
body := string(report)
require.Contains(t, body, "# Post ID: "+post.Id)
require.Contains(t, body, "# Report version: "+model.PostExposureReportVersion)
records := parseExposureCSV(t, report)
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.Equal(t, th.BasicUser.Username, record[1])
}
}
require.True(t, found, "the post author is a channel member and must appear in the report")
})
t.Run("Should return 403 in team reviewer mode for a user not on the team's reviewer list", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
otherClient := th.CreateClient()
th.LoginBasic2WithClient(t, otherClient)
report, resp, err := otherClient.GeneratePostExposureReport(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
}
func readReportArchive(t *testing.T, report []byte) map[string][]byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
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 TestGenerateFlaggedPostReportFileDownloadPolicy(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
withDownloadDecision := func(t *testing.T, channelID string, allowed bool) {
t.Helper()
mockACS := &mocks.AccessControlServiceInterface{}
original := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == channelID && req.Action == model.AccessControlPolicyActionDownloadFileAttachment
})).Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
// Only the decision under test is pinned; unrelated evaluations must not trip the mock.
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil)).Maybe()
}
t.Run("Should include attachments when the download policy allows the reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
withDownloadDecision(t, post.ChannelId, true)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
require.Contains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.NotContains(t, entries, "ATTACHMENTS_OMITTED.txt")
})
t.Run("Should omit attachments without failing the request when the download policy denies the reviewer", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
withDownloadDecision(t, post.ChannelId, false)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
for name := range entries {
require.NotContains(t, name, fileInfo.Id, "no archive entry should be named after an omitted attachment")
}
require.Contains(t, entries, "ATTACHMENTS_OMITTED.txt")
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")
})
}
func TestGenerateFlaggedPostReportAttachmentsOmittedAudit(t *testing.T) {
logFile, err := os.CreateTemp(t.TempDir(), "audit.log")
require.NoError(t, err)
defer logFile.Close()
options := []app.Option{app.WithLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced, "advanced_logging"))}
th := SetupWithServerOptionsAndConfig(t, options, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
cfg.ExperimentalAuditSettings.FileEnabled = model.NewPointer(true)
cfg.ExperimentalAuditSettings.FileName = model.NewPointer(logFile.Name())
}).InitBasic(t)
client := th.Client
// WithLicense seeds the license early enough for the audit sink to come up; the
// test helper clears it again during setup, so re-apply it for content flagging.
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced, "advanced_logging"))
defer th.RemoveLicense(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
omittedPost, _ := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, omittedPost.Id)
completePost, _ := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, completePost.Id)
original := th.App.Srv().Channels().AccessControl
defer func() { th.App.Srv().Channels().AccessControl = original }()
setDownloadDecision := func(allowed bool) {
mockACS := &mocks.AccessControlServiceInterface{}
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
th.App.Srv().Channels().AccessControl = mockACS
}
setDownloadDecision(false)
_, resp, err := client.GenerateFlaggedPostReport(context.Background(), omittedPost.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
setDownloadDecision(true)
_, resp, err = client.GenerateFlaggedPostReport(context.Background(), completePost.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, th.Server.Audit.Flush())
require.NoError(t, logFile.Sync())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
require.NotEmpty(t, data)
omittedEntry := FindAuditEntry(auditLinesForPost(string(data), omittedPost.Id), "generateFlaggedPostReport", th.BasicUser.Id)
require.NotNil(t, omittedEntry, "should find a generateFlaggedPostReport audit entry for the trimmed report")
require.Equal(t, "success", omittedEntry.Status)
require.EqualValues(t, 1, omittedEntry.Parameters["omitted_attachment_count"])
completeEntry := FindAuditEntry(auditLinesForPost(string(data), completePost.Id), "generateFlaggedPostReport", th.BasicUser.Id)
require.NotNil(t, completeEntry, "should find a generateFlaggedPostReport audit entry for the complete report")
require.Equal(t, "success", completeEntry.Status)
require.NotContains(t, completeEntry.Parameters, "omitted_attachment_count", "a complete report has no omission to record")
}
// auditLinesForPost narrows the audit log to the entries mentioning postID, so that
// FindAuditEntry - which returns the first match - can be pointed at a single request.
func auditLinesForPost(data, postID string) string {
var lines []string
for line := range strings.SplitSeq(data, "\n") {
if strings.Contains(line, postID) {
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}
func TestGenerateFlaggedPostReportEvaluatesSessionAttributes(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
cfg.FeatureFlags.SessionAttributes = true
}).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
session, appErr := th.App.GetSession(client.AuthToken)
require.Nil(t, appErr)
setVPNActive := func(t *testing.T, value string) {
t.Helper()
require.NoError(t, th.App.Srv().Store().SessionAttribute().Refresh(session.Id, map[string]any{
model.SessionAttributesPropertyFieldVPNActive: value,
}, model.GetMillis()))
}
// Stands in for a policy that grants the download only while the reviewer's session
// reports an active VPN. Anything the PDP cannot match falls through to a denial, so
// a subject built without session attributes shows up as stripped attachments.
requireVPNActive := func(t *testing.T, channelID string) {
t.Helper()
mockACS := &mocks.AccessControlServiceInterface{}
original := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Action == model.AccessControlPolicyActionDownloadFileAttachment &&
req.Resource.ID == channelID &&
req.Subject.Session[model.SessionAttributesPropertyFieldVPNActive] == "true"
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil)).Maybe()
}
t.Run("Should include attachments when the session attribute satisfies the policy", func(t *testing.T) {
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
setVPNActive(t, "true")
requireVPNActive(t, post.ChannelId)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
require.Contains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.NotContains(t, entries, "ATTACHMENTS_OMITTED.txt")
})
t.Run("Should omit attachments when the session attribute fails the policy", func(t *testing.T) {
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
setVPNActive(t, "false")
requireVPNActive(t, post.ChannelId)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
entries := readReportArchive(t, report)
require.NotContains(t, entries, "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name)
require.Contains(t, entries, "ATTACHMENTS_OMITTED.txt")
})
}