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
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:
695
server/channels/api4/content_flagging_report_test.go
Normal file
695
server/channels/api4/content_flagging_report_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user