Mm 67896 manual cherry pick onto release 10.11 (#35989)

* improves time limit checks

* consistently check for presence of patch fields

* fix variable shadowing in test

* allow idempotent pinning operations with time limit expired

* new utility function for post limit time check

* fix style issue

* Add missing E2E CI files and delivery-platform migration for release-10.11

- Add calculate-playwright-results and calculate-cypress-results GitHub Actions
  (referenced by e2e-tests-playwright-template.yml and e2e-tests-cypress-template.yml
  but never backported to release-10.11)
- Add e2e-tests/playwright/merge.config.mjs (required by merge-reports step)
- Add run-specs Makefile target and server.run_specs.sh (required by run-failed-tests job)
- Fix merge-shard-results step: pin @playwright/test version and add fallback
  for when no blob reports exist (json reporter output used directly)
- Remove pull_request trigger from e2e-tests-ci.yml (delivery-platform migration)
- Remove dead e2e-fulltests-ci.yml and e2e-tests-ci-template.yml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Этот коммит содержится в:
Carlos Garcia
2026-04-14 12:38:57 +02:00
коммит произвёл GitHub
родитель e092af7a33
Коммит b21ef30202
38 изменённых файлов: 59606 добавлений и 934 удалений

Просмотреть файл

@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"time"
@@ -977,6 +978,14 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request, teamId stri
}
}
func postEditTimeLimitExpired(cfg *model.Config, post *model.Post) bool {
limit := *cfg.ServiceSettings.PostEditTimeLimit
if limit == -1 {
return false
}
return model.GetMillis() > post.CreateAt+int64(limit)*1000
}
func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePostId()
if c.Err != nil {
@@ -1035,6 +1044,21 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
post.FileIds = originalPost.FileIds
}
// passing nil props should not have any effect on a post's props
// so, we restore the original props in this case
if post.Props == nil {
post.Props = originalPost.Props
}
if postEditTimeLimitExpired(c.App.Config(), originalPost) &&
(post.Message != originalPost.Message ||
!slices.Equal(post.FileIds, originalPost.FileIds) ||
model.StringInterfaceToJSON(post.GetProps()) != model.StringInterfaceToJSON(originalPost.GetProps()) ||
post.IsPinned != originalPost.IsPinned) {
c.Err = model.NewAppError("UpdatePost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
return
}
// Check upload_file permission only if update is adding NEW files (not just keeping existing ones)
checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost)
if c.Err != nil {
@@ -1051,11 +1075,6 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
post.Id = c.Params.PostId
if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && post.Message != originalPost.Message {
c.Err = model.NewAppError("UpdatePost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
return
}
rpost, isMemberForPreviews, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false})
if err != nil {
c.Err = err
@@ -1104,7 +1123,7 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
isMember := postPatchChecks(c, auditRec, post.Message)
isMember := postPatchChecks(c, auditRec, &post)
if c.Err != nil {
return
}
@@ -1140,7 +1159,7 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) bool {
func postPatchChecks(c *Context, auditRec *model.AuditRecord, patch *model.PostPatch) bool {
originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
if err != nil {
c.SetPermissionError(model.PermissionEditPost)
@@ -1169,7 +1188,7 @@ func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) b
return false
}
if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && message != nil {
if postEditTimeLimitExpired(c.App.Config(), originalPost) && !patch.IsEmpty() {
c.Err = model.NewAppError("patchPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
return isMember
}
@@ -1264,6 +1283,18 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) {
return
}
// Allow no-op requests (e.g. pinning an already-pinned post) regardless of age.
if post.IsPinned == isPinned {
auditRec.Success()
ReturnStatusOK(w)
return
}
if postEditTimeLimitExpired(c.App.Config(), post) {
c.Err = model.NewAppError("saveIsPinnedPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
return
}
patch := &model.PostPatch{}
patch.IsPinned = model.NewPointer(isPinned)
@@ -1557,7 +1588,7 @@ func restorePostVersion(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
isMember := postPatchChecks(c, auditRec, &toRestorePost.Message)
isMember := postPatchChecks(c, auditRec, &model.PostPatch{Message: &toRestorePost.Message, FileIds: &toRestorePost.FileIds})
if c.Err != nil {
return
}

Просмотреть файл

@@ -1565,6 +1565,94 @@ func TestUpdatePost(t *testing.T) {
CheckBadRequestStatus(t, resp)
})
t.Run("change file ids but not message, post too old", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
newFileId := fileResp.FileInfos[0].Id
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
CreateAt: model.GetMillis() - 2000,
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
up := &model.Post{
Id: oldPost.Id,
ChannelId: channel.Id,
Message: oldPost.Message,
FileIds: model.StringArray{newFileId},
}
_, resp, err := client.UpdatePost(context.Background(), oldPost.Id, up)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("change props but not message, post too old", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
CreateAt: model.GetMillis() - 2000,
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
up := &model.Post{
Id: oldPost.Id,
ChannelId: channel.Id,
Message: oldPost.Message,
Props: model.StringInterface{"channel_header": "injected"},
}
_, resp, err := client.UpdatePost(context.Background(), oldPost.Id, up)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("change is_pinned but not message, post too old", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
CreateAt: model.GetMillis() - 2000,
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
up := &model.Post{
Id: oldPost.Id,
ChannelId: channel.Id,
Message: oldPost.Message,
IsPinned: true,
}
_, resp, err := client.UpdatePost(context.Background(), oldPost.Id, up)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("err with integrations-reserved props", func(t *testing.T) {
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
th.App.UpdateConfig(func(cfg *model.Config) {
@@ -2096,6 +2184,110 @@ func TestPatchPost(t *testing.T) {
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id, "should be time limit error")
})
t.Run("patch file ids only, time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
fileResp, _, err := th.SystemAdminClient.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
newFileId := fileResp.FileInfos[0].Id
oldPost := &model.Post{
ChannelId: channel.Id,
Message: "original message",
CreateAt: model.GetMillis() - 2000,
}
oldPost, _, err = th.SystemAdminClient.CreatePost(context.Background(), oldPost)
require.NoError(t, err)
patch := &model.PostPatch{
FileIds: &model.StringArray{newFileId},
}
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("patch props only, time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost := &model.Post{
ChannelId: channel.Id,
Message: "original message",
CreateAt: model.GetMillis() - 2000,
}
oldPost, _, err := th.SystemAdminClient.CreatePost(context.Background(), oldPost)
require.NoError(t, err)
patch := &model.PostPatch{
Props: &model.StringInterface{"channel_header": "injected"},
}
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("patch is_pinned only, time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost := &model.Post{
ChannelId: channel.Id,
Message: "original message",
CreateAt: model.GetMillis() - 2000,
}
oldPost, _, err := th.SystemAdminClient.CreatePost(context.Background(), oldPost)
require.NoError(t, err)
patch := &model.PostPatch{
IsPinned: model.NewPointer(true),
}
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("patch has_reactions only, time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost := &model.Post{
ChannelId: channel.Id,
Message: "original message",
CreateAt: model.GetMillis() - 2000,
}
oldPost, _, err := th.SystemAdminClient.CreatePost(context.Background(), oldPost)
require.NoError(t, err)
patch := &model.PostPatch{
HasReactions: model.NewPointer(true),
}
_, resp, err := th.SystemAdminClient.PatchPost(context.Background(), oldPost.Id, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("err with integrations-reserved props", func(t *testing.T) {
originalHardenedModeSetting := *th.App.Config().ServiceSettings.ExperimentalEnableHardenedMode
th.App.UpdateConfig(func(cfg *model.Config) {
@@ -2259,6 +2451,61 @@ func TestPinPost(t *testing.T) {
defer th.TearDown()
client := th.Client
t.Run("pin post after time limit", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
CreateAt: model.GetMillis() - 2000,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
resp, err := client.PinPost(context.Background(), oldPost.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("idempotent pin/unpin after time limit", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
pinnedPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
IsPinned: true,
CreateAt: model.GetMillis() - 2000,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
unpinnedPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
IsPinned: false,
CreateAt: model.GetMillis() - 2000,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
// Both are no-ops and must succeed regardless of age.
_, err := client.PinPost(context.Background(), pinnedPost.Id)
require.NoError(t, err)
_, err = client.UnpinPost(context.Background(), unpinnedPost.Id)
require.NoError(t, err)
})
post := th.BasicPost
_, err := client.PinPost(context.Background(), post.Id)
require.NoError(t, err)
@@ -2292,6 +2539,28 @@ func TestUnpinPost(t *testing.T) {
defer th.TearDown()
client := th.Client
t.Run("unpin post after time limit", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
IsPinned: true,
CreateAt: model.GetMillis() - 2000,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
resp, err := client.UnpinPost(context.Background(), oldPost.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
pinnedPost := th.CreatePinnedPost()
_, err := client.UnpinPost(context.Background(), pinnedPost.Id)
require.NoError(t, err)
@@ -5700,4 +5969,85 @@ func TestRestorePostVersion(t *testing.T) {
CheckForbiddenStatus(t, response)
require.Nil(t, restoredPost)
})
t.Run("restore post version blocked when time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
// Create post in the past via app layer (bypasses API time limit)
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
CreateAt: model.GetMillis() - 2000,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
// Patch via app layer to create edit history (bypasses API time limit check)
_, _, appErr = th.App.PatchPost(th.Context, oldPost.Id, &model.PostPatch{
Message: model.NewPointer("edited message"),
}, &model.UpdatePostOptions{})
require.Nil(t, appErr)
// Get edit history
editHistory, response, err := client.GetEditHistoryForPost(context.Background(), oldPost.Id)
require.NoError(t, err)
CheckOKStatus(t, response)
require.Equal(t, 1, len(editHistory))
// Restore should be blocked by time limit
_, resp, err := client.RestorePostVersion(context.Background(), oldPost.Id, editHistory[0].Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
t.Run("restore post version with file change blocked when time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
// Upload a file for the original post
fileResp, _, err := client.UploadFile(context.Background(), []byte("data"), th.BasicChannel.Id, "test")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
// Create post in the past with file attached
oldPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
FileIds: model.StringArray{fileId},
CreateAt: model.GetMillis() - 2000,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
// Remove file via app layer to create history entry that has the file
emptyFiles := model.StringArray{}
_, _, appErr = th.App.PatchPost(th.Context, oldPost.Id, &model.PostPatch{
Message: model.NewPointer("edited message"),
FileIds: &emptyFiles,
}, &model.UpdatePostOptions{})
require.Nil(t, appErr)
// Get edit history (should have the entry with the original file)
editHistory, response, err := client.GetEditHistoryForPost(context.Background(), oldPost.Id)
require.NoError(t, err)
CheckOKStatus(t, response)
require.Equal(t, 1, len(editHistory))
require.Equal(t, 1, len(editHistory[0].FileIds))
// Restore (which would re-attach the file) should be blocked by time limit
_, resp, err := client.RestorePostVersion(context.Background(), oldPost.Id, editHistory[0].Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
require.Equal(t, "api.post.update_post.permissions_time_limit.app_error", err.(*model.AppError).Id)
})
}

Просмотреть файл

@@ -168,6 +168,10 @@ type PostPatch struct {
HasReactions *bool `json:"has_reactions"`
}
func (o *PostPatch) IsEmpty() bool {
return o.IsPinned == nil && o.Message == nil && o.Props == nil && o.FileIds == nil && o.HasReactions == nil
}
type PostReminder struct {
TargetTime int64 `json:"target_time"`
// These fields are only used internally for interacting with DB.