Files
team/server/channels/api4/properties_test.go
2026-06-22 09:05:13 +02:00

3780 lines
136 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSessionAttributesFieldEditing(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.SessionAttributes = true
}).InitBasic(t)
groupName := model.SessionAttributesPropertyGroupName
objectType := model.PropertyFieldObjectTypeSession
t.Run("requires an Enterprise Advanced license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), groupName, objectType, model.PropertyFieldSearch{
TargetType: string(model.PropertyFieldTargetLevelSystem),
PerPage: 100,
})
require.Error(t, err)
CheckErrorID(t, err, "api.property.session_attributes.license.app_error")
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
fields, _, err := th.SystemAdminClient.GetPropertyFields(context.Background(), groupName, objectType, model.PropertyFieldSearch{
TargetType: string(model.PropertyFieldTargetLevelSystem),
PerPage: 100,
})
require.NoError(t, err)
require.NotEmpty(t, fields)
var field *model.PropertyField
for _, f := range fields {
if f.Name == model.SessionAttributesPropertyFieldVPNActive {
field = f
break
}
}
require.NotNil(t, field)
require.False(t, field.Protected, "seeded session attribute fields must not be protected")
t.Run("can enable and tune ttl/grace", func(t *testing.T) {
patched, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), groupName, objectType, field.ID, &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
model.SAAttrEnabled: true,
model.SAAttrTTLSeconds: 30,
model.SAAttrGracePeriodSeconds: 30,
},
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, true, patched.Attrs[model.SAAttrEnabled])
})
t.Run("cannot rename", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), groupName, objectType, field.ID, &model.PropertyFieldPatch{
Name: model.NewPointer("renamed_field"),
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
CheckErrorID(t, err, "app.session_attributes.field_immutable.app_error")
})
t.Run("cannot change non-tunable attrs", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), groupName, objectType, field.ID, &model.PropertyFieldPatch{
Attrs: &model.StringInterface{model.SAAttrDisplayName: "Hacked"},
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("cannot be deleted", func(t *testing.T) {
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), groupName, objectType, field.ID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestPropertyRoutesWithClassificationMarkingsFlag(t *testing.T) {
mainHelper.Parallel(t)
// Routes should be available when ClassificationMarkings=true even with IntegratedBoards=false
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = false
cfg.FeatureFlags.ClassificationMarkings = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{
Name: "classification_test",
Version: model.PropertyGroupVersionV2,
})
require.Nil(t, err)
require.NotNil(t, group)
t.Run("create field should succeed with ClassificationMarkings flag", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotEmpty(t, createdField.ID)
})
t.Run("get fields should succeed with ClassificationMarkings flag", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{TargetType: "system"})
require.NoError(t, err)
CheckOKStatus(t, resp)
})
}
func TestCreatePropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
// Register a property group for testing
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, group)
t.Run("unauthenticated request should fail", func(t *testing.T) {
client := model.NewAPIv4Client(th.Client.URL)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
_, resp, err := client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("create protected field should fail", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
Protected: true,
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("non-admin should have permissions forced to member", func(t *testing.T) {
th.LoginBasic(t)
sysadminLevel := model.PermissionLevelSysadmin
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Non-admin should have permissions forced to member
require.NotNil(t, createdField.PermissionField)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionField)
require.NotNil(t, createdField.PermissionValues)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionValues)
require.NotNil(t, createdField.PermissionOptions)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionOptions)
})
t.Run("admin should get default member permissions when not specified", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Admin with no permissions specified should get member defaults
require.NotNil(t, createdField.PermissionField)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionField)
require.NotNil(t, createdField.PermissionValues)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionValues)
require.NotNil(t, createdField.PermissionOptions)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionOptions)
})
t.Run("admin should keep custom permissions when specified", func(t *testing.T) {
sysadminLevel := model.PermissionLevelSysadmin
memberLevel := model.PermissionLevelMember
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &memberLevel,
PermissionOptions: &sysadminLevel,
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Admin should keep the custom permissions
require.NotNil(t, createdField.PermissionField)
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionField)
require.NotNil(t, createdField.PermissionValues)
require.Equal(t, model.PermissionLevelMember, *createdField.PermissionValues)
require.NotNil(t, createdField.PermissionOptions)
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionOptions)
})
t.Run("admin can set permission level=admin on a channel-target field", func(t *testing.T) {
adminLevel := model.PermissionLevelAdmin
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &adminLevel,
PermissionValues: &adminLevel,
PermissionOptions: &adminLevel,
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotNil(t, createdField.PermissionField)
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionField)
require.NotNil(t, createdField.PermissionValues)
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionValues)
require.NotNil(t, createdField.PermissionOptions)
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionOptions)
})
t.Run("invalid group name should fail", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
// Invalid group name with uppercase — route regex won't match, returns 404
_, resp, err := th.Client.CreatePropertyField(context.Background(), "Invalid", "post", field)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("nonexistent group name should fail", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), "nonexistent_group", "post", field)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("successful creation should return 201", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
}
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotEmpty(t, createdField.ID)
require.Equal(t, field.Name, createdField.Name)
require.Equal(t, "post", createdField.ObjectType)
require.Equal(t, group.ID, createdField.GroupID)
require.Equal(t, th.BasicUser.Id, createdField.CreatedBy)
})
t.Run("websocket event should be fired on field creation", func(t *testing.T) {
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
}
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
var receivedField model.PropertyField
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyFieldCreated {
fieldData, ok := event.GetData()["property_field"].(string)
require.True(t, ok)
require.NoError(t, json.Unmarshal([]byte(fieldData), &receivedField))
require.Equal(t, "post", event.GetData()["object_type"])
require.Equal(t, th.BasicChannel.Id, event.GetBroadcast().ChannelId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
require.Equal(t, createdField.ID, receivedField.ID)
require.Equal(t, createdField.Name, receivedField.Name)
})
t.Run("group_id in body should be overridden by group_name from URL", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
GroupID: model.NewId(), // Try to set a different group ID in body
}
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// The group ID should come from the URL's group_name resolution, not the body
require.Equal(t, group.ID, createdField.GroupID)
})
t.Run("system target_type requires ManageSystem permission", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
// Non-admin should be rejected for system-scoped fields
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin should succeed
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, "system", createdField.TargetType)
})
t.Run("channel target_type requires CreatePost permission", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: th.BasicChannel.Id,
}
// BasicUser is a member of BasicChannel and can post — should succeed
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, "channel", createdField.TargetType)
require.Equal(t, th.BasicChannel.Id, createdField.TargetID)
})
t.Run("channel target_type with inaccessible channel should fail", func(t *testing.T) {
th.LoginBasic(t)
// Create a channel BasicUser is not a member of
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
otherChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, otherTeam.Id)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
TargetID: otherChannel.Id,
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("channel target_type without target_id should fail", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "channel",
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("team target_type requires ManageTeam permission", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "team",
TargetID: th.BasicTeam.Id,
}
// BasicUser is a member but not a team admin — should fail
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// SystemAdmin should succeed
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, "team", createdField.TargetType)
require.Equal(t, th.BasicTeam.Id, createdField.TargetID)
})
t.Run("team target_type without target_id should fail", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "team",
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("unknown target_type should fail with bad request", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "unknown",
TargetID: model.NewId(),
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("empty target_type should fail with bad request", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("v1 group should return 404", func(t *testing.T) {
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_create", Version: model.PropertyGroupVersionV1})
require.Nil(t, appErr)
require.NotNil(t, v1Group)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), v1Group.Name, "post", field)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
}
func TestGetPropertyFields(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
// Register property groups for testing
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_get", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, group)
otherGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_get_other", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, otherGroup)
memberLevel := model.PermissionLevelMember
// Create a field in the main group
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
require.NotNil(t, createdField)
// Create a field in a different group
otherField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: otherGroup.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdOtherField, appErr := th.App.CreatePropertyField(th.Context, otherField, false, "")
require.Nil(t, appErr)
require.NotNil(t, createdOtherField)
t.Run("unauthenticated request should fail", func(t *testing.T) {
client := model.NewAPIv4Client(th.Client.URL)
_, resp, err := client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{PerPage: 60})
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("nonexistent group name should fail", func(t *testing.T) {
th.LoginBasic(t)
_, resp, err := th.Client.GetPropertyFields(context.Background(), "nonexistent_group", "post", model.PropertyFieldSearch{PerPage: 60})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("successful get should return fields", func(t *testing.T) {
th.LoginBasic(t)
fields, resp, err := th.Client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{PerPage: 60, TargetType: "system"})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, fields)
found := false
for _, f := range fields {
if f.ID == createdField.ID {
found = true
break
}
}
require.True(t, found, "Created field should be in the response")
})
t.Run("cursor pagination should return subsequent pages", func(t *testing.T) {
th.LoginBasic(t)
// Create additional fields to have enough for pagination
for range 3 {
f := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
_, appErr := th.App.CreatePropertyField(th.Context, f, false, "")
require.Nil(t, appErr)
}
// First request without cursor
page0, resp, err := th.Client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{PerPage: 2, TargetType: "system"})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, page0, 2)
// Use last item from first page as cursor
lastField := page0[len(page0)-1]
page1, resp, err := th.Client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{
PerPage: 2,
TargetType: "system",
CursorID: lastField.ID,
CursorCreateAt: lastField.CreateAt,
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, page1)
// Pages should contain different fields
page0IDs := map[string]bool{}
for _, f := range page0 {
page0IDs[f.ID] = true
}
for _, f := range page1 {
require.False(t, page0IDs[f.ID], "Second page should not contain fields from first page")
}
})
t.Run("invalid cursor should return 400", func(t *testing.T) {
th.LoginBasic(t)
_, resp, err := th.Client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{
PerPage: 2,
TargetType: "system",
CursorID: "not-a-valid-id",
CursorCreateAt: 12345,
})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("get should only return fields from the specified group", func(t *testing.T) {
th.LoginBasic(t)
fields, resp, err := th.Client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{PerPage: 60, TargetType: "system"})
require.NoError(t, err)
CheckOKStatus(t, resp)
// All returned fields must belong to the queried group
for _, f := range fields {
require.Equal(t, group.ID, f.GroupID, "All returned fields should belong to the queried group")
}
// The field from the other group should not be present
for _, f := range fields {
require.NotEqual(t, createdOtherField.ID, f.ID, "Field from other group should not be returned")
}
})
t.Run("v1 group should return 404", func(t *testing.T) {
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_get_fields", Version: model.PropertyGroupVersionV1})
require.Nil(t, appErr)
require.NotNil(t, v1Group)
_, resp, err := th.Client.GetPropertyFields(context.Background(), v1Group.Name, "post", model.PropertyFieldSearch{PerPage: 60})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
}
func TestGetPropertyFieldsScopeAccess(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_scope", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, group)
baseURL := "/properties/groups/" + group.Name + "/post/fields"
// target_type=system without target_id should succeed (system needs no resource scope)
t.Run("system target_type without target_id should succeed", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=system", "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
})
// target_type=system with target_id should succeed
t.Run("system target_type with target_id should succeed", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=system&target_id="+model.NewId(), "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
})
// target_type=channel without target_id should fail with 400
t.Run("channel target_type without target_id should fail", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=channel", "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp.Body.Close()
})
// target_type=channel with target_id the user has access to should succeed
t.Run("channel target_type with accessible channel should succeed", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=channel&target_id="+th.BasicChannel.Id, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
})
// target_type=channel with target_id the user does NOT have access to should fail with 403
t.Run("channel target_type with inaccessible channel should fail", func(t *testing.T) {
th.LoginBasic(t)
// Create a channel in a team the basic user is not a member of
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
otherChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, otherTeam.Id)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=channel&target_id="+otherChannel.Id, "")
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
resp.Body.Close()
})
// target_type=team without target_id should fail with 400
t.Run("team target_type without target_id should fail", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=team", "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp.Body.Close()
})
// target_type=team with target_id the user has access to should succeed
t.Run("team target_type with accessible team should succeed", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=team&target_id="+th.BasicTeam.Id, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
})
// target_type=team with target_id the user does NOT have access to should fail with 403
t.Run("team target_type with inaccessible team should fail", func(t *testing.T) {
th.LoginBasic(t)
// Create a team the basic user is not a member of
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=team&target_id="+otherTeam.Id, "")
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
resp.Body.Close()
})
// No target_type at all should fail with bad request
t.Run("no target_type should fail", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL, "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp.Body.Close()
})
}
func TestGetPropertyFieldsFiltering(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_filter", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, group)
memberLevel := model.PermissionLevelMember
// Create fields with different target_type/target_id combinations
systemField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdSystemField, appErr := th.App.CreatePropertyField(th.Context, systemField, false, "")
require.Nil(t, appErr)
channelField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdChannelField, appErr := th.App.CreatePropertyField(th.Context, channelField, false, "")
require.Nil(t, appErr)
otherChannelField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel2.Id,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdOtherChannelField, appErr := th.App.CreatePropertyField(th.Context, otherChannelField, false, "")
require.Nil(t, appErr)
baseURL := "/properties/groups/" + group.Name + "/post/fields"
decodeFields := func(t *testing.T, resp *http.Response) []*model.PropertyField {
t.Helper()
var fields []*model.PropertyField
err := json.NewDecoder(resp.Body).Decode(&fields)
require.NoError(t, err)
return fields
}
fieldIDs := func(fields []*model.PropertyField) map[string]bool {
ids := make(map[string]bool, len(fields))
for _, f := range fields {
ids[f.ID] = true
}
return ids
}
t.Run("no target_type returns bad request", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL, "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp.Body.Close()
})
t.Run("filter by target_type=system returns only system fields", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=system", "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
ids := fieldIDs(decodeFields(t, resp))
resp.Body.Close()
require.True(t, ids[createdSystemField.ID], "system field should be present")
require.False(t, ids[createdChannelField.ID], "channel field should not be present")
require.False(t, ids[createdOtherChannelField.ID], "other channel field should not be present")
})
t.Run("filter by target_type=channel and target_id returns only matching field", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=channel&target_id="+th.BasicChannel.Id, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
ids := fieldIDs(decodeFields(t, resp))
resp.Body.Close()
require.True(t, ids[createdChannelField.ID], "channel field should be present")
require.False(t, ids[createdSystemField.ID], "system field should not be present")
require.False(t, ids[createdOtherChannelField.ID], "other channel field should not be present")
})
t.Run("filter by target_type=channel and different target_id returns different field", func(t *testing.T) {
th.LoginBasic(t)
resp, err := th.Client.DoAPIGet(context.Background(), baseURL+"?target_type=channel&target_id="+th.BasicChannel2.Id, "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
ids := fieldIDs(decodeFields(t, resp))
resp.Body.Close()
require.True(t, ids[createdOtherChannelField.ID], "other channel field should be present")
require.False(t, ids[createdSystemField.ID], "system field should not be present")
require.False(t, ids[createdChannelField.ID], "first channel field should not be present")
})
}
func TestPatchPropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
cfg.FeatureFlags.PropertyFieldRank = true
}).InitBasic(t)
// Register property groups for testing
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_patch", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, group)
otherGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_patch_other", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, otherGroup)
noneLevel := model.PermissionLevelNone
memberLevel := model.PermissionLevelMember
sysadminLevel := model.PermissionLevelSysadmin
t.Run("unauthenticated request should fail", func(t *testing.T) {
client := model.NewAPIv4Client(th.Client.URL)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
_, resp, err := client.PatchPropertyField(context.Background(), group.Name, "post", model.NewId(), patch)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("protected field update should fail", func(t *testing.T) {
protectedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Protected: true,
PermissionField: &noneLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdProtectedField, appErr := th.App.CreatePropertyField(th.Context, protectedField, true, "")
require.Nil(t, appErr)
require.NotNil(t, createdProtectedField)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdProtectedField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("object type mismatch should fail", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
// Try to update with wrong object_type in URL. Expected 404 to match
// the shape of a non-existent field.
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "channel", createdField.ID, patch)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("patch with wrong group name should fail 404", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
// Try to patch using the other group's name — field belongs to `group`, not `otherGroup`.
// A field not found because of a wrong group must surface as 404, not a generic 500.
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), otherGroup.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
})
t.Run("options-only update should check options permission", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &sysadminLevel, // Only admin can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
require.NotNil(t, createdField)
// Try to update options as a non-admin
th.LoginBasic(t)
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "New Option"},
},
},
}
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin should be able to update options
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("options-only update on non-select field should check field permission", func(t *testing.T) {
// A text field with options in attrs should NOT use the options permission path
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel, // Member can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
require.NotNil(t, createdField)
// Non-admin patches only options on a text field — should require field permission, not options
th.LoginBasic(t)
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
}
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin should succeed (has field permission)
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("options-only update on select field with member options permission should succeed", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel, // Member can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
newOptionID := model.NewId()
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": newOptionID, "name": "New Option"},
},
},
}
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, updatedField)
// Verify the patched option was actually stored, not just that the request succeeded.
opts := updatedField.Attrs[model.PropertyFieldAttributeOptions].([]any)
require.Len(t, opts, 1)
option := opts[0].(map[string]any)
require.Equal(t, newOptionID, option["id"])
require.Equal(t, "New Option", option["name"])
})
t.Run("options-only update on multiselect field with member options permission should succeed", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeMultiselect,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel, // Member can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
newOptionID := model.NewId()
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": newOptionID, "name": "New Option"},
},
},
}
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, updatedField)
// Verify the patched option was actually stored, not just that the request succeeded.
opts := updatedField.Attrs[model.PropertyFieldAttributeOptions].([]any)
require.Len(t, opts, 1)
option := opts[0].(map[string]any)
require.Equal(t, newOptionID, option["id"])
require.Equal(t, "New Option", option["name"])
})
t.Run("options-only update on rank field with member options permission should succeed", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeRank,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1", "rank": 1},
},
},
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel, // Member can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
// A member may re-rank options: options-only patches on rank fields
// use the narrower manage-options permission, same as select/multiselect.
th.LoginBasic(t)
newOptionID := model.NewId()
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": newOptionID, "name": "New Option", "rank": 2},
},
},
}
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, updatedField)
// Verify the patched option (including its rank) was actually stored.
opts := updatedField.Attrs[model.PropertyFieldAttributeOptions].([]any)
require.Len(t, opts, 1)
option := opts[0].(map[string]any)
require.Equal(t, newOptionID, option["id"])
require.Equal(t, "New Option", option["name"])
require.EqualValues(t, 2, option["rank"])
})
t.Run("name and options update on rank field should check field permission not options", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeRank,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1", "rank": 1},
},
},
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel, // Member can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
newName := model.NewId()
patch := &model.PropertyFieldPatch{
Name: &newName,
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "New Option", "rank": 2},
},
},
}
// Member fails — a structural change (name) on a rank field requires the
// full edit-field permission, not the narrower options permission.
th.LoginBasic(t)
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin (has field permission) succeeds.
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, updatedField.Name)
})
t.Run("options-only update on select field with none options permission should fail for all", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &noneLevel, // Nobody can manage options via permission
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "New Option"},
},
},
}
// Non-admin should fail
th.LoginBasic(t)
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin should also fail
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("name and options update on select field should check field permission not options", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel, // Member can manage options
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
newName := model.NewId()
patch := &model.PropertyFieldPatch{
Name: &newName,
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "New Option"},
},
},
}
// Non-admin should fail — name change requires field permission (sysadmin)
th.LoginBasic(t)
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin should succeed
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, updatedField.Name)
})
t.Run("field update should check field permission", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &sysadminLevel, // Only admin can edit field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
require.NotNil(t, createdField)
// Try to update name as a non-admin
th.LoginBasic(t)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// Admin should be able to update name
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("successful update should return updated field", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, updatedField.Name)
require.Equal(t, th.BasicUser.Id, updatedField.UpdatedBy)
})
t.Run("websocket event should be fired on field update", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
var receivedField model.PropertyField
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyFieldUpdated {
fieldData, ok := event.GetData()["property_field"].(string)
require.True(t, ok)
require.NoError(t, json.Unmarshal([]byte(fieldData), &receivedField))
require.Equal(t, "post", event.GetData()["object_type"])
// system-scoped field: no team or channel in broadcast
require.Empty(t, event.GetBroadcast().TeamId)
require.Empty(t, event.GetBroadcast().ChannelId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
require.Equal(t, updatedField.ID, receivedField.ID)
require.Equal(t, newName, receivedField.Name)
})
t.Run("target_id in patch should be silently ignored", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginSystemAdmin(t)
newName := model.NewId()
newTargetID := model.NewId()
patch := &model.PropertyFieldPatch{
Name: &newName,
TargetID: &newTargetID,
}
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, updatedField.Name)
require.Equal(t, createdField.TargetID, updatedField.TargetID)
})
t.Run("target_type in patch should be silently ignored", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginSystemAdmin(t)
newName := model.NewId()
newTargetType := "channel"
patch := &model.PropertyFieldPatch{
Name: &newName,
TargetType: &newTargetType,
}
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, updatedField.Name)
require.Equal(t, "system", updatedField.TargetType)
})
t.Run("options-only patch should preserve other attrs keys via merge semantics", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Attrs: model.StringInterface{
"subtype": "color",
"options": []map[string]any{
{"id": model.NewId(), "name": "Option 1"},
},
},
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
newOptionID := model.NewId()
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": newOptionID, "name": "New Option"},
},
},
}
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
// The "subtype" key should be preserved even though only "options" was patched
require.Equal(t, "color", updatedField.Attrs["subtype"])
// The "options" key should be updated
require.NotNil(t, updatedField.Attrs["options"])
})
t.Run("v1 group should return 404", func(t *testing.T) {
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_patch_field", Version: model.PropertyGroupVersionV1})
require.Nil(t, appErr)
require.NotNil(t, v1Group)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
_, resp, err := th.Client.PatchPropertyField(context.Background(), v1Group.Name, "post", model.NewId(), patch)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
}
func TestDeletePropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
// Register property groups for testing
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_delete", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, group)
otherGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_delete_other", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
require.NotNil(t, otherGroup)
noneLevel := model.PermissionLevelNone
memberLevel := model.PermissionLevelMember
sysadminLevel := model.PermissionLevelSysadmin
t.Run("unauthenticated request should fail", func(t *testing.T) {
client := model.NewAPIv4Client(th.Client.URL)
resp, err := client.DeletePropertyField(context.Background(), group.Name, "post", model.NewId())
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("protected field delete should fail", func(t *testing.T) {
protectedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
Protected: true,
PermissionField: &noneLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdProtectedField, appErr := th.App.CreatePropertyField(th.Context, protectedField, true, "")
require.Nil(t, appErr)
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "post", createdProtectedField.ID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("object type mismatch should fail", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
// Try to delete with wrong object_type in URL. Expected 404 to match
// the shape of a non-existent field.
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "channel", createdField.ID)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("delete with wrong group name should fail 404", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
// Try to delete using the other group's name — field belongs to `group`, not `otherGroup`.
// A field not found because of a wrong group must surface as 404, not a generic 500.
th.LoginSystemAdmin(t)
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), otherGroup.Name, "post", createdField.ID)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
})
t.Run("user without permission should not be able to delete", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &sysadminLevel, // Only admin can edit/delete field
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", createdField.ID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("successful delete should return 200", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", createdField.ID)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("websocket event should be fired on field deletion", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", createdField.ID)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyFieldDeleted {
require.Equal(t, createdField.ID, event.GetData()["field_id"])
require.Equal(t, "post", event.GetData()["object_type"])
// system-scoped field: no team or channel in broadcast
require.Empty(t, event.GetBroadcast().TeamId)
require.Empty(t, event.GetBroadcast().ChannelId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("v1 group should return 404", func(t *testing.T) {
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_delete_field", Version: model.PropertyGroupVersionV1})
require.Nil(t, appErr)
require.NotNil(t, v1Group)
resp, err := th.Client.DeletePropertyField(context.Background(), v1Group.Name, "post", model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
}
func TestIsOptionsOnlyPatch(t *testing.T) {
t.Run("nil attrs is not options-only", func(t *testing.T) {
patch := &model.PropertyFieldPatch{
Name: new("new name"),
}
require.False(t, isOptionsOnlyPatch(patch))
})
t.Run("empty attrs is not options-only", func(t *testing.T) {
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{},
}
require.False(t, isOptionsOnlyPatch(patch))
})
t.Run("attrs with only options is options-only", func(t *testing.T) {
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []any{},
},
}
require.True(t, isOptionsOnlyPatch(patch))
})
t.Run("attrs with options and other keys is not options-only", func(t *testing.T) {
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []any{},
"other": "value",
},
}
require.False(t, isOptionsOnlyPatch(patch))
})
t.Run("name change with options is not options-only", func(t *testing.T) {
patch := &model.PropertyFieldPatch{
Name: new("new name"),
Attrs: &model.StringInterface{
"options": []any{},
},
}
require.False(t, isOptionsOnlyPatch(patch))
})
t.Run("type change is not options-only", func(t *testing.T) {
newType := model.PropertyFieldTypeSelect
patch := &model.PropertyFieldPatch{
Type: &newType,
}
require.False(t, isOptionsOnlyPatch(patch))
})
}
func TestGetPropertyValues(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_values_get", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
// Create a field
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
// Use a real post as the target so target access checks pass
targetID := th.BasicPost.Id
// Create a value via upsert
value := &model.PropertyValue{
TargetID: targetID,
TargetType: "post",
GroupID: group.ID,
FieldID: createdField.ID,
Value: json.RawMessage(`"hello"`),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
}
_, appErr2 := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{value}, "", "", "")
require.Nil(t, appErr2)
t.Run("unauthenticated request should fail", func(t *testing.T) {
client := model.NewAPIv4Client(th.Client.URL)
_, resp, err := client.GetPropertyValues(context.Background(), group.Name, "post", targetID, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("successful get should return values", func(t *testing.T) {
th.LoginBasic(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", targetID, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
found := false
for _, v := range values {
if v.FieldID == createdField.ID {
found = true
require.Equal(t, json.RawMessage(`"hello"`), v.Value)
break
}
}
require.True(t, found, "Created value should be in the response")
})
t.Run("nonexistent group should fail", func(t *testing.T) {
th.LoginBasic(t)
_, resp, err := th.Client.GetPropertyValues(context.Background(), "nonexistent_group", "post", targetID, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("invalid target_id should not match route", func(t *testing.T) {
th.LoginBasic(t)
// The route regex [A-Za-z0-9]+ rejects IDs with invalid characters
_, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", "bad-id", model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("get with no values returns empty array", func(t *testing.T) {
th.LoginBasic(t)
emptyPost := th.CreatePost(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", emptyPost.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Empty(t, values)
})
t.Run("cursor pagination should return subsequent pages", func(t *testing.T) {
th.LoginBasic(t)
// Create a real post and additional fields/values for pagination
paginationPost := th.CreatePost(t)
paginationTarget := paginationPost.Id
for range 4 {
f := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
cf, appErr := th.App.CreatePropertyField(th.Context, f, false, "")
require.Nil(t, appErr)
_, appErr2 := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
TargetID: paginationTarget,
TargetType: "post",
GroupID: group.ID,
FieldID: cf.ID,
Value: json.RawMessage(`"val"`),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
}}, "", "", "")
require.Nil(t, appErr2)
}
// First page
page0, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", paginationTarget, model.PropertyValueSearch{PerPage: 2})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, page0, 2)
// Second page using cursor from last item
last := page0[len(page0)-1]
page1, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", paginationTarget, model.PropertyValueSearch{
PerPage: 2,
CursorID: last.ID,
CursorCreateAt: last.CreateAt,
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, page1)
// Pages should not overlap
page0IDs := map[string]bool{}
for _, v := range page0 {
page0IDs[v.ID] = true
}
for _, v := range page1 {
require.False(t, page0IDs[v.ID], "Second page should not contain values from first page")
}
})
t.Run("v1 group should return 404", func(t *testing.T) {
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_get_values", Version: model.PropertyGroupVersionV1})
require.Nil(t, appErr)
require.NotNil(t, v1Group)
_, resp, err := th.Client.GetPropertyValues(context.Background(), v1Group.Name, "post", th.BasicPost.Id, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
}
func TestPatchPropertyValues(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_values_patch", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
sysadminLevel := model.PermissionLevelSysadmin
noneLevel := model.PermissionLevelNone
// Create fields with different permission levels
memberField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdMemberField, appErr := th.App.CreatePropertyField(th.Context, memberField, false, "")
require.Nil(t, appErr)
adminField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdAdminField, appErr := th.App.CreatePropertyField(th.Context, adminField, false, "")
require.Nil(t, appErr)
noneField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &noneLevel,
PermissionOptions: &memberLevel,
}
createdNoneField, appErr := th.App.CreatePropertyField(th.Context, noneField, false, "")
require.Nil(t, appErr)
// Use a real post as the target so target access checks pass.
targetID := th.BasicPost.Id
t.Run("unauthenticated request should fail", func(t *testing.T) {
client := model.NewAPIv4Client(th.Client.URL)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"test"`)},
}
_, resp, err := client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
t.Run("member can set values on field with values permission member", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"hello"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
require.Equal(t, createdMemberField.ID, values[0].FieldID)
require.Equal(t, json.RawMessage(`"hello"`), values[0].Value)
})
t.Run("websocket event should be fired on values update", func(t *testing.T) {
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"ws-test"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyValuesUpdated {
require.Equal(t, "post", event.GetData()["object_type"])
require.Equal(t, targetID, event.GetData()["target_id"])
// Post target: broadcast should be to the post's channel
require.Equal(t, th.BasicPost.ChannelId, event.GetBroadcast().ChannelId)
// values should be a JSON string
valuesStr, ok := event.GetData()["values"].(string)
require.True(t, ok)
var values []*model.PropertyValue
require.NoError(t, json.Unmarshal([]byte(valuesStr), &values))
require.Len(t, values, 1)
require.Equal(t, createdMemberField.ID, values[0].FieldID)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("non-admin cannot set values on field with values permission sysadmin", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdAdminField.ID, Value: json.RawMessage(`"test"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("admin can set values on field with values permission sysadmin", func(t *testing.T) {
th.LoginSystemAdmin(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdAdminField.ID, Value: json.RawMessage(`"admin-value"`)},
}
values, resp, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
require.Equal(t, json.RawMessage(`"admin-value"`), values[0].Value)
})
t.Run("values permission none blocks everyone", func(t *testing.T) {
th.LoginSystemAdmin(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdNoneField.ID, Value: json.RawMessage(`"test"`)},
}
_, resp, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("batch update with mixed permissions fails all-or-nothing", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"allowed"`)},
{FieldID: createdAdminField.ID, Value: json.RawMessage(`"denied"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("empty body should fail", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("invalid field_id should fail", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: "not-valid", Value: json.RawMessage(`"test"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("field from different group should fail", func(t *testing.T) {
th.LoginBasic(t)
otherGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_values_patch_other", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
otherField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: otherGroup.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdOtherField, appErr := th.App.CreatePropertyField(th.Context, otherField, false, "")
require.Nil(t, appErr)
items := []model.PropertyValuePatchItem{
{FieldID: createdOtherField.ID, Value: json.RawMessage(`"test"`)},
}
_, resp, patchErr := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, patchErr)
CheckNotFoundStatus(t, resp)
})
t.Run("nonexistent group should fail", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"test"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), "nonexistent_group", "post", targetID, items)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
t.Run("field with mismatched object type should fail 404", func(t *testing.T) {
// A field in the same group but scoped to a different ObjectType must not
// be patchable through the URL of a peer ObjectType; the mismatch collapses
// to 404 so callers cannot distinguish "no such field" from "field exists
// but in a different object-type bucket".
userField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdUserField, appErr := th.App.CreatePropertyField(th.Context, userField, false, "")
require.Nil(t, appErr)
th.LoginSystemAdmin(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdUserField.ID, Value: json.RawMessage(`"test"`)},
}
_, resp, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
require.Equal(t, "api.property_field.object_type_mismatch.app_error", err.(*model.AppError).Id)
})
t.Run("channel member can set values on channel-scoped field with values permission member", func(t *testing.T) {
th.LoginBasic(t)
// Create a channel-scoped field with member values permission
channelField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdChannelField, appErr := th.App.CreatePropertyField(th.Context, channelField, false, "")
require.Nil(t, appErr)
items := []model.PropertyValuePatchItem{
{FieldID: createdChannelField.ID, Value: json.RawMessage(`"channel-value"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
require.Equal(t, json.RawMessage(`"channel-value"`), values[0].Value)
})
t.Run("non-member cannot set values on post in a channel they don't belong to", func(t *testing.T) {
privateChannel, chanErr := th.App.CreateChannel(th.Context, &model.Channel{
TeamId: th.BasicTeam.Id,
Type: model.ChannelTypePrivate,
Name: model.NewId(),
DisplayName: "Private Channel",
CreatorId: th.SystemAdminUser.Id,
}, false)
require.Nil(t, chanErr)
// Create a post in the private channel as SystemAdmin (BasicUser is not a member).
privatePost := th.CreatePostWithClient(t, th.SystemAdminClient, privateChannel)
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"should-fail"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", privatePost.Id, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("upsert updates existing value", func(t *testing.T) {
th.LoginBasic(t)
// Set initial value
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"initial"`)},
}
_, _, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, err)
// Update to new value
items[0].Value = json.RawMessage(`"updated"`)
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
require.Equal(t, json.RawMessage(`"updated"`), values[0].Value)
// Verify via GET
got, _, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", targetID, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
found := false
for _, v := range got {
if v.FieldID == createdMemberField.ID {
found = true
require.Equal(t, json.RawMessage(`"updated"`), v.Value)
break
}
}
require.True(t, found)
})
t.Run("returned value should have all fields correctly set", func(t *testing.T) {
th.LoginBasic(t)
valueTargetPost := th.CreatePost(t)
valueTargetID := valueTargetPost.Id
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"test-fields"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", valueTargetID, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
v := values[0]
require.NotEmpty(t, v.ID, "ID should be set")
require.True(t, model.IsValidId(v.ID), "ID should be a valid ID")
require.Equal(t, valueTargetID, v.TargetID)
require.Equal(t, "post", v.TargetType)
require.Equal(t, group.ID, v.GroupID)
require.Equal(t, createdMemberField.ID, v.FieldID)
require.Equal(t, json.RawMessage(`"test-fields"`), v.Value)
require.NotZero(t, v.CreateAt, "CreateAt should be set")
require.NotZero(t, v.UpdateAt, "UpdateAt should be set")
require.Equal(t, v.CreateAt, v.UpdateAt, "CreateAt and UpdateAt should be equal on first insert")
require.Equal(t, int64(0), v.DeleteAt, "DeleteAt should be zero")
require.Equal(t, th.BasicUser.Id, v.CreatedBy)
require.Equal(t, th.BasicUser.Id, v.UpdatedBy)
})
t.Run("upsert should update timestamps and updatedBy correctly", func(t *testing.T) {
// Create initial value as BasicUser
th.LoginBasic(t)
upsertTargetPost := th.CreatePost(t)
upsertTargetID := upsertTargetPost.Id
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"first"`)},
}
created, _, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", upsertTargetID, items)
require.NoError(t, err)
require.Len(t, created, 1)
originalID := created[0].ID
originalCreateAt := created[0].CreateAt
originalUpdatedBy := created[0].UpdatedBy
require.Equal(t, th.BasicUser.Id, originalUpdatedBy)
// Update the same value as SystemAdmin
th.LoginSystemAdmin(t)
items[0].Value = json.RawMessage(`"second"`)
updated, _, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "post", upsertTargetID, items)
require.NoError(t, err)
require.Len(t, updated, 1)
u := updated[0]
// ID should be the same (upsert, not new insert)
require.Equal(t, originalID, u.ID, "ID should not change on upsert")
// CreateAt should not change
require.Equal(t, originalCreateAt, u.CreateAt, "CreateAt should not change on upsert")
// UpdateAt should be >= CreateAt
require.GreaterOrEqual(t, u.UpdateAt, u.CreateAt, "UpdateAt should be >= CreateAt after update")
// UpdatedBy should reflect the new user
require.Equal(t, th.SystemAdminUser.Id, u.UpdatedBy, "UpdatedBy should be the user who performed the update")
// Value should be updated
require.Equal(t, json.RawMessage(`"second"`), u.Value)
})
t.Run("duplicate field IDs should fail", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"first"`)},
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"second"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("v1 group should return 404", func(t *testing.T) {
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_patch_values", Version: model.PropertyGroupVersionV1})
require.Nil(t, appErr)
require.NotNil(t, v1Group)
items := []model.PropertyValuePatchItem{
{FieldID: model.NewId(), Value: json.RawMessage(`"test"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), v1Group.Name, "post", th.BasicPost.Id, items)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
}
func TestGetPropertyValuesUserTargetAccess(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_user_get_access", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
// Create a user-scoped field
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
// Create a value for BasicUser
_, appErr = th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
TargetID: th.BasicUser.Id,
TargetType: "user",
GroupID: group.ID,
FieldID: createdField.ID,
Value: json.RawMessage(`"my-value"`),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
}}, "", "", "")
require.Nil(t, appErr)
t.Run("user can get their own property values", func(t *testing.T) {
th.LoginBasic(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("non-admin can get another user's property values", func(t *testing.T) {
th.LoginBasic2(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("admin can get another user's property values", func(t *testing.T) {
th.LoginSystemAdmin(t)
values, resp, err := th.SystemAdminClient.GetPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("non-admin cannot get values of a user they cannot see", func(t *testing.T) {
// Strip system-wide view_members so UserCanSeeOtherUser falls back to team/channel membership.
th.RemovePermissionFromRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
// Drop BasicUser2 from BasicTeam so they no longer share a team with BasicUser.
resp, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id)
CheckOKStatus(t, resp)
require.NoError(t, err)
th.LoginBasic2(t)
_, resp, err = th.Client.GetPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, model.PropertyValueSearch{PerPage: 60})
CheckForbiddenStatus(t, resp)
require.Error(t, err)
})
}
func TestPatchPropertyValuesUserTargetAccess(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_user_patch_access", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
// Create a user-scoped field
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
t.Run("user can set their own property values", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdField.ID, Value: json.RawMessage(`"self-value"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
require.Equal(t, json.RawMessage(`"self-value"`), values[0].Value)
})
t.Run("non-admin cannot set another user's property values", func(t *testing.T) {
th.LoginBasic2(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdField.ID, Value: json.RawMessage(`"should-fail"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("admin can set another user's property values", func(t *testing.T) {
th.LoginSystemAdmin(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdField.ID, Value: json.RawMessage(`"admin-set"`)},
}
values, resp, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
require.Equal(t, json.RawMessage(`"admin-set"`), values[0].Value)
})
}
func TestGetPropertyValuesChannelTargetAccess(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_chan_get_access", Version: model.PropertyGroupVersionV2})
require.Nil(t, appErr)
memberLevel := model.PermissionLevelMember
createFieldAndValue := func(t *testing.T, channelID string) {
t.Helper()
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "channel",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
_, appErr = th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
TargetID: channelID,
TargetType: "channel",
GroupID: group.ID,
FieldID: createdField.ID,
Value: json.RawMessage(`"val"`),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
}}, "", "", "")
require.Nil(t, appErr)
}
// Create a non-member user
nonMember := th.CreateUser(t)
nonMemberClient := th.CreateClient()
_, _, err := nonMemberClient.Login(context.Background(), nonMember.Email, nonMember.Password)
require.NoError(t, err)
t.Run("public channel - member can read", func(t *testing.T) {
createFieldAndValue(t, th.BasicChannel.Id)
th.LoginBasic(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "channel", th.BasicChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("public channel - non-member cannot read", func(t *testing.T) {
_, resp, err := nonMemberClient.GetPropertyValues(context.Background(), group.Name, "channel", th.BasicChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("private channel - member can read", func(t *testing.T) {
createFieldAndValue(t, th.BasicPrivateChannel.Id)
th.LoginBasic(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "channel", th.BasicPrivateChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("private channel - non-member cannot read", func(t *testing.T) {
_, resp, err := nonMemberClient.GetPropertyValues(context.Background(), group.Name, "channel", th.BasicPrivateChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("DM channel - participant can read", func(t *testing.T) {
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
createFieldAndValue(t, dmChannel.Id)
th.LoginBasic(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "channel", dmChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("DM channel - non-participant cannot read", func(t *testing.T) {
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
createFieldAndValue(t, dmChannel.Id)
_, resp, err := nonMemberClient.GetPropertyValues(context.Background(), group.Name, "channel", dmChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("GM channel - participant can read", func(t *testing.T) {
gmChannel, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, th.SystemAdminUser.Id}, th.BasicUser.Id)
require.Nil(t, appErr)
createFieldAndValue(t, gmChannel.Id)
th.LoginBasic(t)
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "channel", gmChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
t.Run("GM channel - non-participant cannot read", func(t *testing.T) {
gmChannel, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, th.SystemAdminUser.Id}, th.BasicUser.Id)
require.Nil(t, appErr)
createFieldAndValue(t, gmChannel.Id)
_, resp, err := nonMemberClient.GetPropertyValues(context.Background(), group.Name, "channel", gmChannel.Id, model.PropertyValueSearch{PerPage: 60})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestPatchPropertyValuesChannelTargetAccess(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_chan_patch_access", Version: model.PropertyGroupVersionV2})
require.Nil(t, appErr)
memberLevel := model.PermissionLevelMember
createField := func(t *testing.T) *model.PropertyField {
t.Helper()
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "channel",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
return createdField
}
// Create a non-member user
nonMember := th.CreateUser(t)
nonMemberClient := th.CreateClient()
_, _, err := nonMemberClient.Login(context.Background(), nonMember.Email, nonMember.Password)
require.NoError(t, err)
t.Run("public channel - member with manage permission can write", func(t *testing.T) {
f := createField(t)
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"pub-val"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "channel", th.BasicChannel.Id, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
})
t.Run("public channel - non-member cannot write", func(t *testing.T) {
f := createField(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"should-fail"`)},
}
_, resp, err := nonMemberClient.PatchPropertyValues(context.Background(), group.Name, "channel", th.BasicChannel.Id, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("private channel - member with manage permission can write", func(t *testing.T) {
f := createField(t)
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"priv-val"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "channel", th.BasicPrivateChannel.Id, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
})
t.Run("private channel - non-member cannot write", func(t *testing.T) {
f := createField(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"should-fail"`)},
}
_, resp, err := nonMemberClient.PatchPropertyValues(context.Background(), group.Name, "channel", th.BasicPrivateChannel.Id, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("DM channel - participant can write", func(t *testing.T) {
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
f := createField(t)
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"dm-val"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "channel", dmChannel.Id, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
})
t.Run("DM channel - non-participant cannot write", func(t *testing.T) {
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
f := createField(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"should-fail"`)},
}
_, resp, err := nonMemberClient.PatchPropertyValues(context.Background(), group.Name, "channel", dmChannel.Id, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("GM channel - participant can write", func(t *testing.T) {
gmChannel, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, th.SystemAdminUser.Id}, th.BasicUser.Id)
require.Nil(t, appErr)
f := createField(t)
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"gm-val"`)},
}
values, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "channel", gmChannel.Id, items)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Len(t, values, 1)
})
t.Run("GM channel - non-participant cannot write", func(t *testing.T) {
gmChannel, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, th.SystemAdminUser.Id}, th.BasicUser.Id)
require.Nil(t, appErr)
f := createField(t)
items := []model.PropertyValuePatchItem{
{FieldID: f.ID, Value: json.RawMessage(`"should-fail"`)},
}
_, resp, err := nonMemberClient.PatchPropertyValues(context.Background(), group.Name, "channel", gmChannel.Id, items)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestCreatePropertyFieldTeamScopedBroadcast(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_team_broadcast", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
t.Run("team-scoped field broadcast has TeamId set and ChannelId empty", func(t *testing.T) {
// Connect websocket as BasicUser (who is a member of BasicTeam)
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
// Create the field as SystemAdmin (has ManageTeam permission)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "team",
TargetID: th.BasicTeam.Id,
}
createdField, resp, createErr := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
require.NoError(t, createErr)
CheckCreatedStatus(t, resp)
var receivedField model.PropertyField
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyFieldCreated {
fieldData, ok := event.GetData()["property_field"].(string)
require.True(t, ok)
require.NoError(t, json.Unmarshal([]byte(fieldData), &receivedField))
require.Equal(t, "post", event.GetData()["object_type"])
// Team-scoped: TeamId should be set, ChannelId should be empty
require.Equal(t, th.BasicTeam.Id, event.GetBroadcast().TeamId)
require.Empty(t, event.GetBroadcast().ChannelId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
require.Equal(t, createdField.ID, receivedField.ID)
require.Equal(t, createdField.Name, receivedField.Name)
})
}
func TestPatchPropertyValuesChannelObjectTypeBroadcast(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_channel_val_broadcast", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "channel",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
t.Run("channel objectType broadcasts to channel members", func(t *testing.T) {
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdField.ID, Value: json.RawMessage(`"chan-val"`)},
}
_, resp, patchErr := th.Client.PatchPropertyValues(context.Background(), group.Name, "channel", th.BasicChannel.Id, items)
require.NoError(t, patchErr)
CheckOKStatus(t, resp)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyValuesUpdated {
require.Equal(t, "channel", event.GetData()["object_type"])
require.Equal(t, th.BasicChannel.Id, event.GetData()["target_id"])
// Channel target: broadcast should be to that channel
require.Equal(t, th.BasicChannel.Id, event.GetBroadcast().ChannelId)
require.Empty(t, event.GetBroadcast().TeamId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
}
func TestPatchPropertyValuesUserObjectTypeBroadcast(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_user_val_broadcast", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
t.Run("user objectType broadcasts system-wide", func(t *testing.T) {
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdField.ID, Value: json.RawMessage(`"user-val"`)},
}
_, resp, patchErr := th.Client.PatchPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, items)
require.NoError(t, patchErr)
CheckOKStatus(t, resp)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyValuesUpdated {
require.Equal(t, "user", event.GetData()["object_type"])
require.Equal(t, th.BasicUser.Id, event.GetData()["target_id"])
// User target: broadcast should be system-wide (empty team/channel)
require.Empty(t, event.GetBroadcast().TeamId)
require.Empty(t, event.GetBroadcast().ChannelId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
}
func TestUpsertPropertyValuesPSAv1OptOut(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
// PSAv1 fields must live in a v1 group; the marker v2 field uses a separate v2 group.
psav1Group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_psav1_optout_v1", Version: model.PropertyGroupVersionV1})
require.Nil(t, err)
v2Group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_psav1_optout_v2", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
// Create a PSAv1-style field: no ObjectType, meaning it predates
// the websocket broadcast machinery added in PSAv2.
psav1Field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: psav1Group.ID,
TargetType: "system",
}
createdField, appErr := th.App.CreatePropertyField(th.Context, psav1Field, false, "")
require.Nil(t, appErr)
require.Empty(t, createdField.ObjectType, "PSAv1 field should have no ObjectType")
t.Run("upserting values for a PSAv1 field should not publish websocket event", func(t *testing.T) {
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
// We call the app layer directly because the PSAv2 API endpoints
// would reject a request without objectType. This mimics what a
// PSAv1 custom endpoint would do: call UpsertPropertyValues with
// empty objectType/targetID, skipping websocket broadcasting.
values := []*model.PropertyValue{
{
TargetID: th.BasicPost.Id,
TargetType: "post",
GroupID: psav1Group.ID,
FieldID: createdField.ID,
Value: json.RawMessage(`"psav1-value"`),
CreatedBy: th.BasicUser.Id,
UpdatedBy: th.BasicUser.Id,
},
}
result, upsertErr := th.App.UpsertPropertyValues(th.Context, values, "", "", "")
require.Nil(t, upsertErr)
require.Len(t, result, 1)
// Trigger a known marker event so we can detect when we've
// drained the websocket channel. If a property_values_updated
// event arrives before the marker, the test fails.
memberLevel := model.PermissionLevelMember
markerField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: v2Group.ID,
ObjectType: "post",
TargetType: "channel",
TargetID: th.BasicChannel.Id,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
_, markerErr := th.App.CreatePropertyField(th.Context, markerField, false, "")
require.Nil(t, markerErr)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
require.NotEqual(t, model.WebsocketEventPropertyValuesUpdated, event.EventType(),
"PSAv1 opt-out should not produce a property_values_updated event")
if event.EventType() == model.WebsocketEventPropertyFieldCreated {
// Marker arrived, no values event was seen
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
}
func TestPatchPropertyValuesMultiValuePayload(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_multi_val_payload", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
memberLevel := model.PermissionLevelMember
// Create three fields
var createdFields []*model.PropertyField
for i := range 3 {
f := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "post",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
cf, fieldErr := th.App.CreatePropertyField(th.Context, f, false, "")
require.Nil(t, fieldErr, "failed to create field %d", i)
createdFields = append(createdFields, cf)
}
t.Run("multi-value patch includes all values in websocket event", func(t *testing.T) {
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
items := []model.PropertyValuePatchItem{
{FieldID: createdFields[0].ID, Value: json.RawMessage(`"val-0"`)},
{FieldID: createdFields[1].ID, Value: json.RawMessage(`"val-1"`)},
{FieldID: createdFields[2].ID, Value: json.RawMessage(`"val-2"`)},
}
targetID := th.BasicPost.Id
_, resp, patchErr := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.NoError(t, patchErr)
CheckOKStatus(t, resp)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyValuesUpdated {
require.Equal(t, "post", event.GetData()["object_type"])
require.Equal(t, targetID, event.GetData()["target_id"])
valuesStr, ok := event.GetData()["values"].(string)
require.True(t, ok)
var values []*model.PropertyValue
require.NoError(t, json.Unmarshal([]byte(valuesStr), &values))
require.Len(t, values, 3, "websocket event should contain all 3 values")
// Verify all field IDs are present
fieldIDs := map[string]bool{}
for _, v := range values {
fieldIDs[v.FieldID] = true
}
for _, f := range createdFields {
require.True(t, fieldIDs[f.ID], "field %s should be in the websocket event values", f.ID)
}
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
}
func TestLinkedProperties(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_linked_properties", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
sysadminLevel := model.PermissionLevelSysadmin
memberLevel := model.PermissionLevelMember
t.Run("create template field requires sysadmin", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "system",
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "template", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotNil(t, createdField.PermissionField)
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionField)
require.NotNil(t, createdField.PermissionValues)
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionValues)
require.NotNil(t, createdField.PermissionOptions)
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionOptions)
})
t.Run("create template field as non-admin fails", func(t *testing.T) {
th.LoginBasic(t)
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "system",
}
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "template", field)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("create linked field with valid source", func(t *testing.T) {
// Create a template field as the source
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
Attrs: model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "Option A"},
{"id": model.NewId(), "name": "Option B"},
},
},
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
sourceID := createdSource.ID
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "system",
LinkedFieldID: &sourceID,
}
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "user", field)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, createdSource.Type, createdField.Type)
require.NotNil(t, createdField.Attrs)
require.NotNil(t, createdField.Attrs["options"])
})
t.Run("patch linked field rejects type change", func(t *testing.T) {
// Create source + linked field via App
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
sourceID := createdSource.ID
linkedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
LinkedFieldID: &sourceID,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdLinked, appErr := th.App.CreatePropertyField(th.Context, linkedField, false, "")
require.Nil(t, appErr)
newType := model.PropertyFieldTypeText
patch := &model.PropertyFieldPatch{Type: &newType}
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "user", createdLinked.ID, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("patch linked field rejects options change", func(t *testing.T) {
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
sourceID := createdSource.ID
linkedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
LinkedFieldID: &sourceID,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdLinked, appErr := th.App.CreatePropertyField(th.Context, linkedField, false, "")
require.Nil(t, appErr)
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
"options": []map[string]any{
{"id": model.NewId(), "name": "New Option"},
},
},
}
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "user", createdLinked.ID, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("patch linked field allows name change", func(t *testing.T) {
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
sourceID := createdSource.ID
linkedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
LinkedFieldID: &sourceID,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdLinked, appErr := th.App.CreatePropertyField(th.Context, linkedField, false, "")
require.Nil(t, appErr)
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "user", createdLinked.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Equal(t, newName, updatedField.Name)
})
t.Run("patch unlink clears LinkedFieldID", func(t *testing.T) {
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
sourceID := createdSource.ID
linkedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
LinkedFieldID: &sourceID,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdLinked, appErr := th.App.CreatePropertyField(th.Context, linkedField, false, "")
require.Nil(t, appErr)
emptyID := ""
patch := &model.PropertyFieldPatch{LinkedFieldID: &emptyID}
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "user", createdLinked.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Nil(t, updatedField.LinkedFieldID)
})
t.Run("patch existing field rejects setting LinkedFieldID", func(t *testing.T) {
// Create a regular (non-linked) field
regularField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdRegular, appErr := th.App.CreatePropertyField(th.Context, regularField, false, "")
require.Nil(t, appErr)
someID := model.NewId()
patch := &model.PropertyFieldPatch{LinkedFieldID: &someID}
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "user", createdRegular.ID, patch)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("delete source with dependents returns 409", func(t *testing.T) {
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
sourceID := createdSource.ID
linkedField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
LinkedFieldID: &sourceID,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
_, appErr = th.App.CreatePropertyField(th.Context, linkedField, false, "")
require.Nil(t, appErr)
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "template", createdSource.ID)
require.Error(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode)
})
t.Run("get values for template returns 400", func(t *testing.T) {
th.LoginBasic(t)
_, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "template", model.NewId(), model.PropertyValueSearch{PerPage: 10})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("patch values for template returns 400", func(t *testing.T) {
th.LoginBasic(t)
items := []model.PropertyValuePatchItem{
{FieldID: model.NewId(), Value: json.RawMessage(`"val"`)},
}
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "template", model.NewId(), items)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("patch source field options propagates to linked fields", func(t *testing.T) {
// Create a template (source) with one option
optA := map[string]any{"id": model.NewId(), "name": "PropTestA"}
sourceField := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: "system",
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{optA},
},
}
createdSource, appErr := th.App.CreatePropertyField(th.Context, sourceField, false, "")
require.Nil(t, appErr)
// Create two linked fields and keep their IDs
sourceID := createdSource.ID
var linkedIDs []string
for range 2 {
linked := &model.PropertyField{
Name: "PropLinked-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
GroupID: group.ID,
ObjectType: "user",
TargetType: "system",
LinkedFieldID: &sourceID,
PermissionField: &memberLevel,
PermissionValues: &memberLevel,
PermissionOptions: &memberLevel,
}
createdLinked, lErr := th.App.CreatePropertyField(th.Context, linked, false, "")
require.Nil(t, lErr)
linkedIDs = append(linkedIDs, createdLinked.ID)
}
// Patch the source field's options via the API — add a second option
optB := map[string]any{"id": model.NewId(), "name": "PropTestB"}
patch := &model.PropertyFieldPatch{
Attrs: &model.StringInterface{
model.PropertyFieldAttributeOptions: []any{optA, optB},
},
}
updatedSource, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "template", createdSource.ID, patch)
require.NoError(t, err)
CheckOKStatus(t, resp)
// Source should have 2 options now
sourceOpts := updatedSource.Attrs[model.PropertyFieldAttributeOptions].([]any)
require.Len(t, sourceOpts, 2)
// Fetch each linked field and verify propagation
for _, linkedID := range linkedIDs {
lf, lfErr := th.App.GetPropertyField(th.Context, group.ID, linkedID)
require.Nil(t, lfErr)
opts := lf.Attrs[model.PropertyFieldAttributeOptions].([]any)
require.Len(t, opts, 2, "linked field %s should have 2 options after propagation", linkedID)
}
})
}
func TestSystemObjectType(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.IntegratedBoards = true
}).InitBasic(t)
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_system_object_type", Version: model.PropertyGroupVersionV2})
require.Nil(t, err)
t.Run("non-admin cannot create a system field", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
_, resp, createErr := th.Client.CreatePropertyField(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, field)
require.Error(t, createErr)
CheckForbiddenStatus(t, resp)
})
t.Run("sysadmin creates a system field with canonical target and sysadmin-default permissions", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
// Intentionally submit mismatched target fields to confirm the server canonicalizes them.
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: model.NewId(),
}
created, resp, createErr := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, field)
require.NoError(t, createErr)
CheckCreatedStatus(t, resp)
require.Equal(t, model.PropertyFieldObjectTypeSystem, created.ObjectType)
require.Equal(t, string(model.PropertyFieldTargetLevelSystem), created.TargetType)
require.Empty(t, created.TargetID)
require.NotNil(t, created.PermissionField)
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionField)
require.NotNil(t, created.PermissionValues)
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
})
t.Run("any authenticated user can list system fields", func(t *testing.T) {
// No target_type query required when object_type=system.
fields, resp, getErr := th.Client.GetPropertyFields(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, model.PropertyFieldSearch{})
require.NoError(t, getErr)
CheckOKStatus(t, resp)
require.NotEmpty(t, fields)
})
t.Run("legacy values GET route rejects system object type", func(t *testing.T) {
// Force a URL that matches {object_type}/values/{target_id}. "system" is a valid
// character set for {target_id}, so the route is reachable even though the handler
// must reject it.
_, resp, getErr := th.Client.GetPropertyValues(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, model.PropertyValueSystemTargetID, model.PropertyValueSearch{})
require.Error(t, getErr)
CheckBadRequestStatus(t, resp)
})
t.Run("legacy values PATCH route rejects system object type", func(t *testing.T) {
// Mirrors the GET rejection: the legacy URL pattern matches system/system, so the
// route is reachable and the handler must reject it pointing callers to the
// dedicated system route.
items := []model.PropertyValuePatchItem{
{FieldID: model.NewId(), Value: json.RawMessage(`"ignored"`)},
}
_, resp, patchErr := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, model.PropertyValueSystemTargetID, items)
require.Error(t, patchErr)
CheckBadRequestStatus(t, resp)
})
t.Run("system PATCH rejects template field IDs", func(t *testing.T) {
// Create the template field directly via the app layer so the test stays
// focused on the value-patch handler. Template fields are definition-only
// and must never accept values, regardless of entry point.
templateField := &model.PropertyField{
GroupID: group.ID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
}
created, appErr := th.App.CreatePropertyField(th.Context, templateField, false, "")
require.Nil(t, appErr)
items := []model.PropertyValuePatchItem{
{FieldID: created.ID, Value: json.RawMessage(`"ignored"`)},
}
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.Error(t, patchErr)
// Mismatch (template field ObjectType != system route's objectType)
// collapses to 404 to match the executePatchPropertyField shape.
CheckNotFoundStatus(t, resp)
})
t.Run("system field round-trips a value via the dedicated route", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
}
created, resp, createErr := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, field)
require.NoError(t, createErr)
CheckCreatedStatus(t, resp)
items := []model.PropertyValuePatchItem{
{FieldID: created.ID, Value: json.RawMessage(`"hello-system"`)},
}
// Non-admin cannot write a system value.
_, resp, patchErr := th.Client.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.Error(t, patchErr)
CheckForbiddenStatus(t, resp)
// Sysadmin writes succeed and store the sentinel TargetID.
upserted, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.NoError(t, patchErr)
CheckOKStatus(t, resp)
require.Len(t, upserted, 1)
require.Equal(t, model.PropertyValueSystemTargetID, upserted[0].TargetID)
require.Equal(t, model.PropertyValueTargetTypeSystem, upserted[0].TargetType)
// Any authed user can read.
values, resp, getErr := th.Client.GetSystemPropertyValues(context.Background(), group.Name, model.PropertyValueSearch{})
require.NoError(t, getErr)
CheckOKStatus(t, resp)
found := false
for _, v := range values {
if v.FieldID == created.ID {
require.Equal(t, model.PropertyValueSystemTargetID, v.TargetID)
require.Equal(t, json.RawMessage(`"hello-system"`), v.Value)
found = true
}
}
require.True(t, found, "expected to find the system value we just wrote")
})
t.Run("system value patch broadcasts system-wide", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
}
created, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: field.Name,
Type: field.Type,
ObjectType: model.PropertyFieldObjectTypeSystem,
TargetType: string(model.PropertyFieldTargetLevelSystem),
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
}, false, "")
require.Nil(t, appErr)
th.LoginBasic(t)
webSocketClient := th.CreateConnectedWebSocketClient(t)
items := []model.PropertyValuePatchItem{
{FieldID: created.ID, Value: json.RawMessage(`"broadcast-me"`)},
}
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.NoError(t, patchErr)
CheckOKStatus(t, resp)
require.Eventually(t, func() bool {
select {
case event := <-webSocketClient.EventChannel:
if event.EventType() == model.WebsocketEventPropertyValuesUpdated &&
event.GetData()["object_type"] == model.PropertyFieldObjectTypeSystem {
require.Equal(t, model.PropertyValueSystemTargetID, event.GetData()["target_id"])
// System target: broadcast should be system-wide (empty team/channel)
require.Empty(t, event.GetBroadcast().TeamId)
require.Empty(t, event.GetBroadcast().ChannelId)
return true
}
default:
return false
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("system field permissions are canonicalized to sysadmin even when admin requests member", func(t *testing.T) {
// Without canonicalization, an admin could POST member-level
// permissions on a system field; the per-field permission check
// would then resolve "member" against TargetType=system, which
// hasPropertyFieldScopeAccess treats as "any authenticated user",
// effectively making the field publicly mutable.
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
PermissionField: model.NewPointer(model.PermissionLevelMember),
PermissionValues: model.NewPointer(model.PermissionLevelMember),
PermissionOptions: model.NewPointer(model.PermissionLevelMember),
}
created, resp, createErr := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, field)
require.NoError(t, createErr)
CheckCreatedStatus(t, resp)
require.NotNil(t, created.PermissionField)
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionField)
require.NotNil(t, created.PermissionValues)
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
require.NotNil(t, created.PermissionOptions)
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionOptions)
// With permissions canonicalized to sysadmin, the existing
// per-field permission check rejects non-admin patch/delete
// without any additional handler-level floor.
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
_, resp, patchErr := th.Client.PatchPropertyField(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, created.ID, patch)
require.Error(t, patchErr)
CheckForbiddenStatus(t, resp)
resp, deleteErr := th.Client.DeletePropertyField(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, created.ID)
require.Error(t, deleteErr)
CheckForbiddenStatus(t, resp)
})
t.Run("legacy values PATCH route rejects body referencing a system field ID", func(t *testing.T) {
// Cross-route reference: a non-system route receives a body whose
// field IDs belong to a system-typed field. Without the ObjectType
// match check this would either bypass the system-write sysadmin
// gate or persist a value whose TargetType disagrees with
// field.ObjectType.
systemField, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeSystem,
TargetType: string(model.PropertyFieldTargetLevelSystem),
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
PermissionValues: model.NewPointer(model.PermissionLevelMember),
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
}, false, "")
require.Nil(t, appErr)
items := []model.PropertyValuePatchItem{
{FieldID: systemField.ID, Value: json.RawMessage(`"smuggled"`)},
}
// Even sysadmin should be rejected — this is a structural check on
// the route, not a permission check. Mismatch collapses to 404 to
// match the executePatchPropertyField/executeDeletePropertyField shape.
_, resp, patchErr := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, model.PropertyFieldObjectTypeUser, th.SystemAdminUser.Id, items)
require.Error(t, patchErr)
CheckNotFoundStatus(t, resp)
})
t.Run("system values PATCH route rejects body referencing a non-system field ID", func(t *testing.T) {
// Inverse of the previous case: the dedicated system route is
// reachable only by sysadmin, but it must still reject body field
// IDs whose ObjectType isn't system, otherwise rows get persisted
// with TargetType=system pointing at fields that attach to other
// entity types.
postField, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypePost,
TargetType: string(model.PropertyFieldTargetLevelSystem),
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
}, false, "")
require.Nil(t, appErr)
items := []model.PropertyValuePatchItem{
{FieldID: postField.ID, Value: json.RawMessage(`"misrouted"`)},
}
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.Error(t, patchErr)
CheckNotFoundStatus(t, resp)
})
}