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,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
// These tests cover the endpoints that build their own file metadata instead of going
// through sanitizeFileAttachmentsForUser. With the channel's Access Control Policy denying
// download_file_attachment, every direct file endpoint returns 403, and none of these
// endpoints may hand back the file's rendered thumbnail for the same file, user and moment.
//
// Sent posts and their historical versions are redacted outright. Drafts and scheduled posts
// keep their metadata, because the author still has to see and send their own unsent
// content, and only the thumbnail is withheld.
//
// The policy is switched to deny only after the upload, which is the case Access Control
// Policies exist to cover: revoking access to content the user could previously reach.
// setFileDownloadPolicy enables ABAC and installs an access control service that returns
// the given decision for download_file_attachment while allowing every other action.
func setFileDownloadPolicy(t *testing.T, th *TestHelper, allowed bool) *eMocks.AccessControlServiceInterface {
t.Helper()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
mockACS := &eMocks.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
})).Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil)).Maybe()
// Channel teardown and policy reads touch the service without being part of the
// behaviour under test.
mockACS.On("GetPolicy", mock.Anything, mock.AnythingOfType("string")).
Return(nil, (*model.AppError)(nil)).Maybe()
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).
Return((*model.AppError)(nil)).Maybe()
return mockACS
}
// requireDirectFileEndpointsDenied is the precondition shared by every case below: the
// endpoints that already enforce the policy must reject this user for this file.
func requireDirectFileEndpointsDenied(t *testing.T, th *TestHelper, fileID string) {
t.Helper()
_, resp, err := th.Client.GetFile(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.GetFileThumbnail(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.GetFilePreview(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.GetFileInfo(context.Background(), fileID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
// requireNoThumbnailLeaked asserts that no rendered thumbnail reached a user the policy
// denies. This is the file's actual content, so no endpoint may serve it.
func requireNoThumbnailLeaked(t *testing.T, metadata *model.PostMetadata, where string) {
t.Helper()
if metadata == nil {
return
}
for _, info := range metadata.Files {
if info.MiniPreview != nil {
require.Failf(t, "leaked a thumbnail", "%s: returned a %d byte mini preview for file %s", where, len(*info.MiniPreview), info.Id)
}
}
}
// requireNoLeakedFileMetadata additionally asserts that no file metadata survived, which is
// what sent posts and their historical versions must do.
func requireNoLeakedFileMetadata(t *testing.T, metadata *model.PostMetadata, where string) {
t.Helper()
requireNoThumbnailLeaked(t, metadata, where)
if metadata == nil {
return
}
require.Emptyf(t, metadata.Files, "%s: leaked file metadata to a user denied download_file_attachment", where)
}
func uploadTestImage(t *testing.T, th *TestHelper, channelID, name string) *model.FileInfo {
t.Helper()
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
uploaded, _, err := th.Client.UploadFile(context.Background(), data, channelID, name)
require.NoError(t, err)
require.Len(t, uploaded.FileInfos, 1)
require.NotNil(t, uploaded.FileInfos[0].MiniPreview, "precondition: the uploaded image should have a mini preview to leak")
return uploaded.FileInfos[0]
}
func TestGetDraftsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
info := uploadTestImage(t, th, th.BasicChannel.Id, "draft-attachment.png")
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "draft with a gated attachment",
FileIds: model.StringArray{info.Id},
})
require.NoError(t, err)
setFileDownloadPolicy(t, th, false)
requireDirectFileEndpointsDenied(t, th, info.Id)
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, drafts, 1)
requireNoThumbnailLeaked(t, drafts[0].Metadata, "GET /users/me/teams/{team_id}/drafts")
// The author must still be able to see and send their own unsent draft. The web client
// builds the outgoing post's file_ids from metadata.files, so dropping the metadata
// would silently strip the attachment when the draft is sent.
require.Len(t, drafts[0].Metadata.Files, 1, "the author should still see their own draft attachment")
require.Equal(t, info.Id, drafts[0].Metadata.Files[0].Id)
require.Equal(t, model.StringArray{info.Id}, drafts[0].FileIds)
}
func TestGetScheduledPostsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
info := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-attachment.png")
created, _, err := th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
Draft: model.Draft{
CreateAt: model.GetMillis(),
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "scheduled post with a gated attachment",
FileIds: model.StringArray{info.Id},
},
ScheduledAt: model.GetMillis() + 100000,
})
require.NoError(t, err)
require.NotNil(t, created)
setFileDownloadPolicy(t, th, false)
requireDirectFileEndpointsDenied(t, th, info.Id)
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
require.NoError(t, err)
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
scheduled := scheduledPosts[th.BasicTeam.Id][0]
requireNoThumbnailLeaked(t, scheduled.Metadata, "GET /posts/scheduled/team/{team_id}")
// The server sends this post from the stored FileIds when it fires, so dropping the
// metadata would make a client-side "send now" and the scheduled send disagree.
require.Len(t, scheduled.Metadata.Files, 1, "the author should still see their own scheduled attachment")
require.Equal(t, info.Id, scheduled.Metadata.Files[0].Id)
require.Equal(t, model.StringArray{info.Id}, scheduled.FileIds)
}
func TestGetEditHistoryForPostDoesNotLeakFilesDeniedByPolicy(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
info := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-attachment.png")
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "post with a gated attachment",
FileIds: model.StringArray{info.Id},
})
require.NoError(t, err)
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
Message: new("post with a gated attachment, edited"),
})
require.NoError(t, err)
setFileDownloadPolicy(t, th, false)
requireDirectFileEndpointsDenied(t, th, info.Id)
// The live post is redacted for this user. The historical versions of the same post are
// expected to match that, and are the control that proves the redaction works at all.
live, _, err := th.Client.GetPost(context.Background(), post.Id, "")
require.NoError(t, err)
requireNoLeakedFileMetadata(t, live.Metadata, "GET /posts/{post_id} (control)")
require.Empty(t, live.FileIds, "control: the live post should not expose file ids either")
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
require.NoError(t, err)
require.NotEmpty(t, history)
for _, version := range history {
require.NotNil(t, version.Metadata)
requireNoLeakedFileMetadata(t, version.Metadata, "GET /posts/{post_id}/edit_history")
require.Empty(t, version.FileIds, "the historical version should not expose file ids either")
require.Equal(t, 1, version.Metadata.RedactedFileCount)
}
}
// TestFileMetadataServedWhenPolicyAllows is the other half of the contract. With a policy
// active but allowing the action, all three endpoints must still serve the metadata and the
// thumbnail, so enforcement cannot be satisfied by redacting unconditionally.
func TestFileMetadataServedWhenPolicyAllows(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PermissionPolicies = true
}).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
requireFileServed := func(metadata *model.PostMetadata, fileID, where string) {
t.Helper()
require.NotNil(t, metadata, where)
require.Len(t, metadata.Files, 1, "%s: should serve the file metadata", where)
require.Equal(t, fileID, metadata.Files[0].Id, where)
require.NotNil(t, metadata.Files[0].MiniPreview, "%s: should serve the thumbnail", where)
require.Zero(t, metadata.RedactedFileCount, where)
}
draftFile := uploadTestImage(t, th, th.BasicChannel.Id, "draft-allowed.png")
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "draft with an allowed attachment",
FileIds: model.StringArray{draftFile.Id},
})
require.NoError(t, err)
scheduledFile := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-allowed.png")
_, _, err = th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
Draft: model.Draft{
CreateAt: model.GetMillis(),
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "scheduled post with an allowed attachment",
FileIds: model.StringArray{scheduledFile.Id},
},
ScheduledAt: model.GetMillis() + 100000,
})
require.NoError(t, err)
postFile := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-allowed.png")
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "post with an allowed attachment",
FileIds: model.StringArray{postFile.Id},
})
require.NoError(t, err)
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
Message: new("post with an allowed attachment, edited"),
})
require.NoError(t, err)
setFileDownloadPolicy(t, th, true)
_, _, err = th.Client.GetFileThumbnail(context.Background(), postFile.Id)
require.NoError(t, err, "precondition: the policy should allow download_file_attachment")
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, drafts, 1)
requireFileServed(drafts[0].Metadata, draftFile.Id, "GET /users/me/teams/{team_id}/drafts")
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
require.NoError(t, err)
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
requireFileServed(scheduledPosts[th.BasicTeam.Id][0].Metadata, scheduledFile.Id, "GET /posts/scheduled/team/{team_id}")
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
require.NoError(t, err)
require.NotEmpty(t, history)
for _, version := range history {
requireFileServed(version.Metadata, postFile.Id, "GET /posts/{post_id}/edit_history")
require.Equal(t, model.StringArray{postFile.Id}, version.FileIds)
}
}