Add opt-in post property value hydration to GET /posts/{id}

This commit is contained in:
Miguel de la Cruz
2026-08-26 17:10:53 +02:00
parent ba9a39ef41
commit 9b5bb54f62
9 changed files with 769 additions and 5 deletions
+9 -1
View File
@@ -584,6 +584,11 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
propertyGroupID := resolvePropertyGroupParam(c, r)
if c.Err != nil {
return
}
post, err, isMember := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), includeDeleted)
if err != nil {
c.Err = err
@@ -596,7 +601,10 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{
IncludePriority: true,
PropertyGroupID: propertyGroupID,
})
post, previewIsMember, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
@@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type postPropertyTestHelper struct {
*TestHelper
groupID string
}
func setupPostPropertyTest(t *testing.T) *postPropertyTestHelper {
t.Helper()
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.PostAttributes = true
}).InitBasic(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.PostAttributesPropertyGroupName)
require.Nil(t, appErr)
return &postPropertyTestHelper{TestHelper: th, groupID: group.ID}
}
func (th *postPropertyTestHelper) createField(t *testing.T, groupID, name string) *model.PropertyField {
t.Helper()
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: groupID,
Name: name,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypePost,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: th.BasicChannel.Id,
}, false, "")
require.Nil(t, appErr)
return field
}
func (th *postPropertyTestHelper) setValue(t *testing.T, groupID, postID, fieldID, raw string) {
t.Helper()
_, appErr := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
TargetID: postID,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupID,
FieldID: fieldID,
Value: json.RawMessage(raw),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
}}, model.PropertyValueTargetTypePost, postID, "")
require.Nil(t, appErr)
}
func TestGetPostWithPropertyGroups(t *testing.T) {
mainHelper.Parallel(t)
t.Run("returns values for the requested group", func(t *testing.T) {
th := setupPostPropertyTest(t)
field := th.createField(t, th.groupID, "sensitivity")
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: []string{model.PostAttributesPropertyGroupName}})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, post.Metadata)
require.Len(t, post.Metadata.PropertyValues, 1)
assert.Equal(t, field.ID, post.Metadata.PropertyValues[0].FieldID)
})
// Etag correctness rests on a write to a property value bumping the post's UpdateAt, since
// Post.Etag() is Etag(id, UpdateAt). Until that bump exists, a conditional request serves a
// 304 and the client keeps rendering the previous attribute values. The bump is deliberately
// out of scope here -- it belongs to the write path -- so this test is the standing record of
// what the read path depends on, and it should be un-skipped when that lands.
t.Run("changing a value moves the post etag", func(t *testing.T) {
t.Skip("Requires property value writes to bump the post's UpdateAt; landing in a follow-up PR")
th := setupPostPropertyTest(t)
groups := []string{model.PostAttributesPropertyGroupName}
field := th.createField(t, th.groupID, "sensitivity")
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"internal"`)
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: groups})
require.NoError(t, err)
etag := resp.Etag
require.NotEmpty(t, etag)
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
_, resp, err = th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: groups})
require.NoError(t, err)
require.NotEqual(t, etag, resp.Etag,
"a value change must bump the post UpdateAt, or conditional requests serve stale values")
// And a conditional request with the stale etag must return the new value, not a 304.
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, etag,
model.GetPostOptions{IncludePropertyGroups: groups})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, post.Metadata.PropertyValues, 1)
assert.JSONEq(t, `"confidential"`, string(post.Metadata.PropertyValues[0].Value))
})
t.Run("etags are served whether or not values are requested", func(t *testing.T) {
th := setupPostPropertyTest(t)
groups := []string{model.PostAttributesPropertyGroupName}
field := th.createField(t, th.groupID, "sensitivity")
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
_, plainResp, err := th.Client.GetPost(context.Background(), th.BasicPost.Id, "")
require.NoError(t, err)
require.NotEmpty(t, plainResp.Etag)
_, groupResp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: groups})
require.NoError(t, err)
assert.NotEmpty(t, groupResp.Etag)
assert.Equal(t, plainResp.Etag, groupResp.Etag)
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, groupResp.Etag,
model.GetPostOptions{IncludePropertyGroups: groups})
require.NoError(t, err)
CheckEtag(t, post, resp)
})
t.Run("parameter validation", func(t *testing.T) {
th := setupPostPropertyTest(t)
t.Run("more than one group is rejected before any lookup", func(t *testing.T) {
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: []string{model.PostAttributesPropertyGroupName, model.BoardsPropertyGroupName}})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("unknown group is a 404", func(t *testing.T) {
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: []string{"nope"}})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("session_attributes stays gated", func(t *testing.T) {
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: []string{model.SessionAttributesPropertyGroupName}})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
})
// Both options travel on the same request, so the struct is exercised rather than merely
// declared: a deleted post returns no values, and the include_deleted permission still applies.
t.Run("combines include_deleted with property groups", func(t *testing.T) {
th := setupPostPropertyTest(t)
opts := model.GetPostOptions{
IncludeDeleted: true,
IncludePropertyGroups: []string{model.PostAttributesPropertyGroupName},
}
field := th.createField(t, th.groupID, "sensitivity")
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
_, err := th.SystemAdminClient.DeletePost(context.Background(), th.BasicPost.Id)
require.NoError(t, err)
// include_deleted still requires manage_system, group or no group.
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "", opts)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
post, resp, err := th.SystemAdminClient.GetPostWithOptions(context.Background(), th.BasicPost.Id, "", opts)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, th.BasicPost.Id, post.Id)
// A deleted post is never hydrated, and that is not an unavailability either.
assert.Empty(t, post.Metadata.PropertyValues)
assert.False(t, post.Metadata.PropertyValuesUnavailable)
})
// 4.H — the parameter is group-agnostic by design; any PSAv2 group is fair game.
t.Run("accepts any PSAv2 group", func(t *testing.T) {
th := setupPostPropertyTest(t)
boards, appErr := th.App.GetPropertyGroup(th.Context, model.BoardsPropertyGroupName)
require.Nil(t, appErr)
field := th.createField(t, boards.ID, "board-attr")
th.setValue(t, boards.ID, th.BasicPost.Id, field.ID, `"in-progress"`)
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: []string{model.BoardsPropertyGroupName}})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, post.Metadata.PropertyValues, 1)
assert.Equal(t, field.ID, post.Metadata.PropertyValues[0].FieldID)
})
// 4.H2 — content_flagging is a V1 group with its own gated routes. A 200 with no values would
// read as "this post has no moderation values", which is exactly the wrong signal, so the
// status itself is the assertion.
t.Run("a V1 group is rejected rather than returning nothing", func(t *testing.T) {
th := setupPostPropertyTest(t)
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
model.GetPostOptions{IncludePropertyGroups: []string{model.ContentFlaggingGroupName}})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// And nothing leaks through the generic parameter for a post that does carry V1 values.
post, plainResp, err := th.Client.GetPost(context.Background(), th.BasicPost.Id, "")
require.NoError(t, err)
CheckOKStatus(t, plainResp)
assert.Empty(t, post.Metadata.PropertyValues)
})
}
+29
View File
@@ -62,6 +62,35 @@ func getV2Group(c *Context, callerName string) *model.PropertyGroup {
return group
}
// resolvePropertyGroupParam reads the include_property_groups query parameter and resolves it to a
// property group ID. Returns "" when the parameter is absent, which callers pass straight through to
// PreparePostForClientOpts as "do not hydrate". On any validation failure it sets c.Err, so callers
// check that rather than the returned value.
func resolvePropertyGroupParam(c *Context, r *http.Request) string {
raw := r.URL.Query().Get("include_property_groups")
if raw == "" {
return ""
}
// The parameter is plural and comma-separated for forward compatibility, but hydrating more
// than one group per request is not supported yet. Reject rather than silently taking the
// first, which would return a body that does not match what was asked for.
names := strings.Split(raw, ",")
if len(names) != 1 || strings.TrimSpace(names[0]) == "" {
c.Err = model.NewAppError("resolvePropertyGroupParam", "api.post.property_groups.too_many.app_error",
nil, "", http.StatusBadRequest)
return ""
}
c.Params.GroupName = strings.TrimSpace(names[0])
group := getV2Group(c, "resolvePropertyGroupParam")
if group == nil {
return ""
}
return group.ID
}
func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireGroupName().RequireObjectType()
if c.Err != nil {
+25 -4
View File
@@ -185,6 +185,13 @@ func (a *App) OverrideIconURLIfEmoji(rctx request.CTX, post *model.Post) {
}
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
post, _ := a.preparePostForClient(rctx, originalPost, opts)
return post
}
// preparePostForClient additionally reports whether a prepare step deliberately blanked the post's
// metadata, which callers that batch work across a page need in order to skip those posts.
func (a *App) preparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) (*model.Post, bool) {
post := originalPost.Clone()
// Proxy image links before constructing metadata so that requests go through the proxy
@@ -195,11 +202,15 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
post.Metadata = &model.PostMetadata{}
}
// Set when a prepare step deliberately blanks the metadata without returning, so later steps
// can tell "nothing to show yet" from "nothing there".
metadataWithheld := false
if post.DeleteAt > 0 && !opts.RetainContent {
// For deleted posts we don't fill out metadata nor do we return the post content
post.Message = ""
post.Metadata = &model.PostMetadata{}
return post
return post, true
}
// Emojis and reaction counts
@@ -232,6 +243,7 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
// if the post is a scheduled post, we don't reset the metadata
} else {
post.Metadata = &model.PostMetadata{}
metadataWithheld = true
}
}
}
@@ -252,7 +264,11 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
}
}
return post
if opts.PropertyGroupID != "" && !metadataWithheld {
a.hydratePropertyValues(rctx, []*model.Post{post}, opts.PropertyGroupID)
}
return post, metadataWithheld
}
func (a *App) preparePostFilesForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
@@ -266,10 +282,15 @@ func (a *App) preparePostFilesForClient(rctx request.CTX, post *model.Post, opts
}
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
post := a.PreparePostForClient(rctx, originalPost, opts)
post, _ := a.preparePostForClientWithEmbedsAndImages(rctx, originalPost, opts)
return post
}
func (a *App) preparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) (*model.Post, bool) {
post, metadataWithheld := a.preparePostForClient(rctx, originalPost, opts)
post = a.getEmbedsAndImages(rctx, post, opts.IsNewPost)
post = a.preparePostFilesForClient(rctx, post, opts)
return post
return post, metadataWithheld
}
func (a *App) getEmbedsAndImages(rctx request.CTX, post *model.Post, isNewPost bool) *model.Post {
+154
View File
@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"cmp"
"net/http"
"slices"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// hydratePropertyValues attaches the group's property values onto each post's metadata.
//
// Callers pass posts they have already established are eligible to carry values; this function
// deliberately does not re-derive any visibility rule.
//
// It never returns an error. A failed lookup marks the affected posts unavailable and logs, so
// that a caller asking for attributes still gets its posts back. A client can then tell "this
// post has no values" from "the values could not be loaded" instead of rendering an unmarked post.
func (a *App) hydratePropertyValues(rctx request.CTX, posts []*model.Post, groupID string) {
eligible := make([]*model.Post, 0, len(posts))
for _, post := range posts {
if post == nil || post.Metadata == nil {
continue
}
eligible = append(eligible, post)
}
if len(eligible) == 0 {
return
}
// Identify the viewer so a redaction hook on the group can filter per caller.
rctx = RequestContextWithCallerID(rctx, rctx.Session().UserId)
// Fields are scoped per channel, so posts are grouped by channel.
byChannel := map[string][]*model.Post{}
for _, post := range eligible {
byChannel[post.ChannelId] = append(byChannel[post.ChannelId], post)
}
for channelID, channelPosts := range byChannel {
if err := a.hydrateChannelPropertyValues(rctx, channelID, channelPosts, groupID); err != nil {
rctx.Logger().Warn(
"Failed to hydrate post property values",
mlog.String("group_id", groupID),
mlog.String("channel_id", channelID),
mlog.Int("post_count", len(channelPosts)),
mlog.Err(err),
)
markPropertyValuesUnavailable(channelPosts)
}
}
}
// hydrateChannelPropertyValues hydrates one channel's worth of posts, whose applicable fields are
// all the same. Returns an error for the caller to translate into the unavailability marker.
func (a *App) hydrateChannelPropertyValues(rctx request.CTX, channelID string, posts []*model.Post, groupID string) error {
channel, appErr := a.GetChannel(rctx, channelID)
if appErr != nil {
return appErr
}
fields, appErr := a.SearchPropertyFields(rctx, groupID, model.PropertyFieldSearchOpts{
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
ChannelID: channelID,
TeamID: channel.TeamId,
PerPage: model.PostAttributesMaxApplicableFields + 1,
})
if appErr != nil {
return appErr
}
// Over the bound means the per-target cap was bypassed and the page may be truncated, so the
// value set below would be silently incomplete. Report unavailable rather than partial.
if len(fields) > model.PostAttributesMaxApplicableFields {
return model.NewAppError("hydratePropertyValues", "app.post.property_values.field_bound_exceeded.app_error",
nil, "applicable field count exceeds the per-post bound", http.StatusInternalServerError)
}
applicable := make(map[string]*model.PropertyField, len(fields))
for _, field := range fields {
applicable[field.ID] = field
}
if len(applicable) == 0 {
return nil
}
postIDs := make([]string, 0, len(posts))
for _, post := range posts {
postIDs = append(postIDs, post.Id)
}
slices.Sort(postIDs)
values, appErr := a.SearchPropertyValues(rctx, groupID, model.PropertyValueSearchOpts{
TargetType: model.PropertyValueTargetTypePost,
TargetIDs: postIDs,
PerPage: len(postIDs)*model.PostAttributesMaxApplicableFields + 1,
})
if appErr != nil {
return appErr
}
if len(values) > len(postIDs)*model.PostAttributesMaxApplicableFields {
return model.NewAppError("hydratePropertyValues", "app.post.property_values.value_bound_exceeded.app_error",
nil, "value count exceeds the per-page bound", http.StatusInternalServerError)
}
byPost := make(map[string][]*model.PropertyValue, len(posts))
for _, value := range values {
// A value whose field is gone, or whose field is not a post field, is stale: the field
// list is the authority on what applies.
if _, ok := applicable[value.FieldID]; !ok {
continue
}
byPost[value.TargetID] = append(byPost[value.TargetID], value)
}
for _, post := range posts {
postValues := byPost[post.Id]
if len(postValues) == 0 {
continue
}
// Field creation order, so a client renders attributes consistently across posts and
// across requests. FieldID breaks ties for fields created in the same millisecond.
slices.SortFunc(postValues, func(a, b *model.PropertyValue) int {
if c := cmp.Compare(applicable[a.FieldID].CreateAt, applicable[b.FieldID].CreateAt); c != 0 {
return c
}
return cmp.Compare(a.FieldID, b.FieldID)
})
post.Metadata.PropertyValues = postValues
}
return nil
}
// markPropertyValuesUnavailable tells the client the lookup failed, so it renders the post as
// "attributes unknown" rather than as carrying none.
func markPropertyValuesUnavailable(posts []*model.Post) {
for _, post := range posts {
if post == nil || post.Metadata == nil {
continue
}
post.Metadata.PropertyValuesUnavailable = true
}
}
@@ -0,0 +1,288 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type propertyValuesTestHelper struct {
*TestHelper
groupID string
}
func setupPropertyValuesTest(t *testing.T) *propertyValuesTestHelper {
t.Helper()
th := Setup(t).InitBasic(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.PostAttributesPropertyGroupName)
require.Nil(t, appErr)
return &propertyValuesTestHelper{TestHelper: th, groupID: group.ID}
}
// createField registers a post-object field at the given target. targetID is empty for system.
func (th *propertyValuesTestHelper) createField(t *testing.T, targetType, targetID, name string) *model.PropertyField {
t.Helper()
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: th.groupID,
Name: name,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypePost,
TargetType: targetType,
TargetID: targetID,
}, false, "")
require.Nil(t, appErr)
return field
}
func (th *propertyValuesTestHelper) setValue(t *testing.T, post *model.Post, field *model.PropertyField, raw string) {
t.Helper()
_, appErr := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: th.groupID,
FieldID: field.ID,
Value: json.RawMessage(raw),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
}}, model.PropertyValueTargetTypePost, post.Id, "")
require.Nil(t, appErr)
}
func (th *propertyValuesTestHelper) post(t *testing.T, channel *model.Channel) *model.Post {
t.Helper()
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
Message: "post " + model.NewId(),
}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
return post
}
// prepare re-fetches the post so metadata is built from scratch, as a real request would.
func (th *propertyValuesTestHelper) prepare(t *testing.T, post *model.Post, groupID string) *model.Post {
t.Helper()
fresh, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, appErr)
fresh.Metadata = nil
return th.App.PreparePostForClient(th.Context, fresh, &model.PreparePostForClientOpts{PropertyGroupID: groupID})
}
func valueFieldIDs(post *model.Post) []string {
ids := make([]string, 0, len(post.Metadata.PropertyValues))
for _, v := range post.Metadata.PropertyValues {
ids = append(ids, v.FieldID)
}
return ids
}
func TestHydratePropertyValues(t *testing.T) {
mainHelper.Parallel(t)
t.Run("attaches values only when a group is requested", func(t *testing.T) {
th := setupPropertyValuesTest(t)
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
post := th.post(t, th.BasicChannel)
th.setValue(t, post, field, `"confidential"`)
hydrated := th.prepare(t, post, th.groupID)
require.Len(t, hydrated.Metadata.PropertyValues, 1)
assert.Equal(t, field.ID, hydrated.Metadata.PropertyValues[0].FieldID)
assert.JSONEq(t, `"confidential"`, string(hydrated.Metadata.PropertyValues[0].Value))
// Absent group means no lookup and no values, and crucially no unavailability marker:
// the client must not read "not requested" as "failed to load".
plain := th.prepare(t, post, "")
assert.Empty(t, plain.Metadata.PropertyValues)
assert.False(t, plain.Metadata.PropertyValuesUnavailable)
})
t.Run("success with no values leaves the unavailable flag absent", func(t *testing.T) {
th := setupPropertyValuesTest(t)
th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
post := th.post(t, th.BasicChannel)
hydrated := th.prepare(t, post, th.groupID)
assert.Empty(t, hydrated.Metadata.PropertyValues)
assert.False(t, hydrated.Metadata.PropertyValuesUnavailable)
// omitempty must keep both keys off the wire entirely.
raw, err := json.Marshal(hydrated.Metadata)
require.NoError(t, err)
var decoded map[string]json.RawMessage
require.NoError(t, json.Unmarshal(raw, &decoded))
assert.NotContains(t, decoded, "property_values")
assert.NotContains(t, decoded, "property_values_unavailable")
})
t.Run("applies system, team and channel fields but not another channel's", func(t *testing.T) {
th := setupPropertyValuesTest(t)
otherChannel := th.CreateChannel(t, th.BasicTeam)
systemField := th.createField(t, string(model.PropertyFieldTargetLevelSystem), "", "system-attr")
teamField := th.createField(t, string(model.PropertyFieldTargetLevelTeam), th.BasicTeam.Id, "team-attr")
channelField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "channel-attr")
otherField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), otherChannel.Id, "other-attr")
post := th.post(t, th.BasicChannel)
th.setValue(t, post, systemField, `"s"`)
th.setValue(t, post, teamField, `"t"`)
th.setValue(t, post, channelField, `"c"`)
// A value for a field scoped to a different channel must be dropped, not returned.
th.setValue(t, post, otherField, `"x"`)
hydrated := th.prepare(t, post, th.groupID)
assert.ElementsMatch(t, []string{systemField.ID, teamField.ID, channelField.ID}, valueFieldIDs(hydrated))
assert.NotContains(t, valueFieldIDs(hydrated), otherField.ID)
})
t.Run("a DM has no team scope", func(t *testing.T) {
th := setupPropertyValuesTest(t)
dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
systemField := th.createField(t, string(model.PropertyFieldTargetLevelSystem), "", "system-attr")
teamField := th.createField(t, string(model.PropertyFieldTargetLevelTeam), th.BasicTeam.Id, "team-attr")
dmField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), dm.Id, "dm-attr")
post := th.post(t, dm)
th.setValue(t, post, systemField, `"s"`)
th.setValue(t, post, dmField, `"c"`)
// No team, so a team-scoped value cannot apply.
th.setValue(t, post, teamField, `"t"`)
hydrated := th.prepare(t, post, th.groupID)
assert.ElementsMatch(t, []string{systemField.ID, dmField.ID}, valueFieldIDs(hydrated))
})
t.Run("drops values whose field is deleted or is not a post field", func(t *testing.T) {
th := setupPropertyValuesTest(t)
liveField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "live")
deletedField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "doomed")
post := th.post(t, th.BasicChannel)
th.setValue(t, post, liveField, `"live"`)
th.setValue(t, post, deletedField, `"stale"`)
require.Nil(t, th.App.DeletePropertyField(th.Context, th.groupID, deletedField.ID, false, ""))
hydrated := th.prepare(t, post, th.groupID)
assert.Equal(t, []string{liveField.ID}, valueFieldIDs(hydrated))
})
t.Run("orders values by field creation time", func(t *testing.T) {
th := setupPropertyValuesTest(t)
// Names are deliberately reverse-alphabetical relative to creation order.
first := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "zulu")
second := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "alpha")
post := th.post(t, th.BasicChannel)
// Values written in the opposite order to prove output order tracks the field, not the value.
th.setValue(t, post, second, `"2"`)
th.setValue(t, post, first, `"1"`)
hydrated := th.prepare(t, post, th.groupID)
require.Len(t, hydrated.Metadata.PropertyValues, 2)
assert.Equal(t, []string{first.ID, second.ID}, valueFieldIDs(hydrated))
})
t.Run("a deleted post is never hydrated", func(t *testing.T) {
th := setupPropertyValuesTest(t)
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
post := th.post(t, th.BasicChannel)
th.setValue(t, post, field, `"confidential"`)
_, appErr := th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
require.Nil(t, appErr)
fresh, appErr := th.App.GetSinglePost(th.Context, post.Id, true)
require.Nil(t, appErr)
fresh.Metadata = nil
hydrated := th.App.PreparePostForClient(th.Context, fresh,
&model.PreparePostForClientOpts{PropertyGroupID: th.groupID, IncludeDeleted: true})
assert.Empty(t, hydrated.Metadata.PropertyValues)
assert.False(t, hydrated.Metadata.PropertyValuesUnavailable)
})
t.Run("values survive sanitization", func(t *testing.T) {
th := setupPropertyValuesTest(t)
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
post := th.post(t, th.BasicChannel)
th.setValue(t, post, field, `"confidential"`)
hydrated := th.prepare(t, post, th.groupID)
require.Len(t, hydrated.Metadata.PropertyValues, 1)
sanitized, _, appErr := th.App.SanitizePostMetadataForUser(th.Context, hydrated, th.BasicUser.Id)
require.Nil(t, appErr)
require.NotNil(t, sanitized.Metadata)
assert.Len(t, sanitized.Metadata.PropertyValues, 1)
})
// An unrevealed burn-on-read post has its metadata blanked for non-authors. Unlike the deleted-post
// case, that blanking does not return early, so hydration running afterwards would re-attach
// attribute values to a post whose content is being withheld. The author, who is allowed to see the
// post, still gets them.
t.Run("an unrevealed burn-on-read post", func(t *testing.T) {
th := setupPropertyValuesTest(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = new(true)
})
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
borPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn after reading",
Type: model.PostTypeBurnOnRead,
}
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*60*1000))
post, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
th.setValue(t, post, field, `"confidential"`)
viewer := th.CreateUser(t)
th.LinkUserToTeam(t, viewer, th.BasicTeam)
th.AddUserToChannel(t, viewer, th.BasicChannel)
prepareAs := func(t *testing.T, userID string) *model.Post {
t.Helper()
original := th.Context.Session().UserId
th.Context.Session().UserId = userID
t.Cleanup(func() { th.Context.Session().UserId = original })
fresh, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, appErr)
fresh.Metadata = nil
return th.App.PreparePostForClient(th.Context, fresh,
&model.PreparePostForClientOpts{PropertyGroupID: th.groupID})
}
t.Run("a non-author gets no values while the post is unrevealed", func(t *testing.T) {
hydrated := prepareAs(t, viewer.Id)
require.NotNil(t, hydrated.Metadata)
assert.Empty(t, hydrated.Metadata.PropertyValues)
// Not an unavailability either: the values were withheld, not unloadable.
assert.False(t, hydrated.Metadata.PropertyValuesUnavailable)
})
t.Run("the author gets values in the same conditions", func(t *testing.T) {
hydrated := prepareAs(t, th.BasicUser.Id)
require.NotNil(t, hydrated.Metadata)
require.Len(t, hydrated.Metadata.PropertyValues, 1)
assert.Equal(t, field.ID, hydrated.Metadata.PropertyValues[0].FieldID)
})
})
}
+12
View File
@@ -3174,6 +3174,10 @@
"id": "api.post.posts_by_ids.invalid_body.request_error",
"translation": "The number of Post IDs received has exceeded the maximum size of {{.MaxLength}}"
},
{
"id": "api.post.property_groups.too_many.app_error",
"translation": "Only one property group can be requested at a time."
},
{
"id": "api.post.reveal_post.cannot_reveal_own_post.app_error",
"translation": "You cannot reveal your own burn-on-read post."
@@ -8432,6 +8436,14 @@
"id": "app.post.permanent_delete_post.error",
"translation": "Failed to permanently delete post."
},
{
"id": "app.post.property_values.field_bound_exceeded.app_error",
"translation": "The number of attributes that apply to this post exceeds the supported limit."
},
{
"id": "app.post.property_values.value_bound_exceeded.app_error",
"translation": "The number of attribute values for these posts exceeds the supported limit."
},
{
"id": "app.post.restore_post_version.get_single.app_error",
"translation": "Failed to get the old post version."
+17
View File
@@ -3622,6 +3622,23 @@ func (c *Client4) GetPostIncludeDeleted(ctx context.Context, postId string, etag
return DecodeJSONFromResponse[*Post](r)
}
// GetPostWithOptions gets a single post, applying every option the endpoint supports.
func (c *Client4) GetPostWithOptions(ctx context.Context, postId string, etag string, opts GetPostOptions) (*Post, *Response, error) {
values := url.Values{}
if opts.IncludeDeleted {
values.Set("include_deleted", c.boolString(true))
}
if len(opts.IncludePropertyGroups) > 0 {
values.Set("include_property_groups", strings.Join(opts.IncludePropertyGroups, ","))
}
r, err := c.doAPIGetWithQuery(ctx, c.postRoute(postId), values, etag)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[*Post](r)
}
// DeletePost deletes a post from the provided post id string.
func (c *Client4) DeletePost(ctx context.Context, postId string) (*Response, error) {
r, err := c.doAPIDelete(ctx, c.postRoute(postId))
+11
View File
@@ -453,6 +453,17 @@ type GetPostsSinceForSyncOptions struct {
ExcludedPostTypes []string // post types to exclude from sync
}
// GetPostOptions are the options for fetching a single post. Its plural sibling
// GetPostsOptions covers the list endpoints.
type GetPostOptions struct {
// IncludeDeleted returns the post even if it is soft-deleted.
IncludeDeleted bool
// IncludePropertyGroups names the property groups whose values should be hydrated onto the
// post's metadata.
IncludePropertyGroups []string
}
type GetPostsOptions struct {
UserId string
ChannelId string