mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 21:27:40 -05:00
5798 lines
215 KiB
Go
5798 lines
215 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 TestSessionAttributesFeatureFlagGate(t *testing.T) {
|
|
// Routes are registered through the ClassificationMarkings flag so the
|
|
// generic Properties API is reachable while the SessionAttributes feature
|
|
// flag stays off.
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.SessionAttributes = false
|
|
cfg.FeatureFlags.ClassificationMarkings = true
|
|
}).InitBasic(t)
|
|
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
|
|
|
groupName := model.SessionAttributesPropertyGroupName
|
|
objectType := model.PropertyFieldObjectTypeSession
|
|
search := model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
PerPage: 100,
|
|
}
|
|
|
|
t.Run("admin read returns 501 when feature flag is off", func(t *testing.T) {
|
|
_, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), groupName, objectType, search)
|
|
require.Error(t, err)
|
|
CheckErrorID(t, err, "api.property.session_attributes.license.app_error")
|
|
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("non-admin read returns 501 when feature flag is off", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), groupName, objectType, search)
|
|
require.Error(t, err)
|
|
CheckErrorID(t, err, "api.property.session_attributes.license.app_error")
|
|
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
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 TestPropertyRoutesWithPostAttributesFlag(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
|
|
// Routes should be available when PostAttributes=true alone, with every
|
|
// other peer property flag off. The post_attributes group itself is
|
|
// registered unconditionally at startup (see RegisterBuiltinGroups in
|
|
// server.go); only the generic Properties API route registration is
|
|
// gated by the OR condition in InitProperties.
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.IntegratedBoards = false
|
|
cfg.FeatureFlags.ManagedChannelCategories = false
|
|
cfg.FeatureFlags.ClassificationMarkings = false
|
|
cfg.FeatureFlags.SessionAttributes = false
|
|
cfg.FeatureFlags.PostAttributes = true
|
|
}).InitBasic(t)
|
|
|
|
groupName := model.PostAttributesPropertyGroupName
|
|
|
|
t.Run("create field should succeed with PostAttributes flag", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "system",
|
|
}
|
|
|
|
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), groupName, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
require.NotEmpty(t, createdField.ID)
|
|
})
|
|
|
|
t.Run("get fields should succeed with PostAttributes flag", func(t *testing.T) {
|
|
_, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), groupName, "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("scope admin may pin permission levels", func(t *testing.T) {
|
|
// promote th.BasicUser to be a channel admin of th.BasicChannel
|
|
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
|
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
t.Cleanup(func() {
|
|
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
|
})
|
|
th.LoginBasic(t)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
memberLevel := model.PermissionLevelMember
|
|
sysadminLevel := model.PermissionLevelSysadmin
|
|
noneLevel := model.PermissionLevelNone
|
|
|
|
channelField := func() *model.PropertyField {
|
|
return &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
}
|
|
}
|
|
|
|
t.Run("channel admin pins admin on a channel-scoped field", func(t *testing.T) {
|
|
field := channelField()
|
|
field.PermissionField = &adminLevel
|
|
field.PermissionValues = &memberLevel
|
|
field.PermissionOptions = &adminLevel
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionOptions)
|
|
})
|
|
|
|
t.Run("omitted slots are default-filled", func(t *testing.T) {
|
|
field := channelField()
|
|
field.PermissionField = &adminLevel
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionOptions)
|
|
})
|
|
|
|
t.Run("pinning sysadmin on permission_field is refused", func(t *testing.T) {
|
|
field := channelField()
|
|
field.PermissionField = &sysadminLevel
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
CheckErrorID(t, err, "api.property_field.create.creator_cannot_edit.app_error")
|
|
})
|
|
|
|
t.Run("sysadmin and none are allowed on the values and options slots", func(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
values *model.PermissionLevel
|
|
options *model.PermissionLevel
|
|
}{
|
|
"values sysadmin": {values: &sysadminLevel},
|
|
"options sysadmin": {options: &sysadminLevel},
|
|
"values none": {values: &noneLevel},
|
|
"options none": {options: &noneLevel},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
field := channelField()
|
|
field.PermissionField = &adminLevel
|
|
field.PermissionValues = tc.values
|
|
field.PermissionOptions = tc.options
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
if tc.values != nil {
|
|
require.Equal(t, *tc.values, *created.PermissionValues)
|
|
}
|
|
if tc.options != nil {
|
|
require.Equal(t, *tc.options, *created.PermissionOptions)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("permission_field none is refused", func(t *testing.T) {
|
|
// none means nobody can edit, so the anti-lockout check catches it
|
|
// and returns 403.
|
|
field := channelField()
|
|
field.PermissionField = &noneLevel
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
CheckErrorID(t, err, "api.property_field.create.creator_cannot_edit.app_error")
|
|
})
|
|
|
|
t.Run("sysadmin submitting permission_field none is refused the same way", func(t *testing.T) {
|
|
// Pins the status-code change for system admins too.
|
|
field := channelField()
|
|
field.TargetType = "system"
|
|
field.TargetID = ""
|
|
field.PermissionField = &noneLevel
|
|
|
|
_, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
CheckErrorID(t, err, "api.property_field.create.creator_cannot_edit.app_error")
|
|
})
|
|
})
|
|
|
|
t.Run("plain channel member still has levels silently defaulted", func(t *testing.T) {
|
|
// th.BasicUser2 is a plain member of th.BasicChannel and is never
|
|
// promoted, so this pins the unchanged non-scope-admin behaviour.
|
|
th.LoginBasic2(t)
|
|
t.Cleanup(func() { 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,
|
|
}
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionOptions)
|
|
})
|
|
|
|
t.Run("team admin may pin on a team-scoped field", func(t *testing.T) {
|
|
th.LoginTeamAdmin(t)
|
|
t.Cleanup(func() { th.LoginBasic(t) })
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "team",
|
|
TargetID: th.BasicTeam.Id,
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &adminLevel,
|
|
PermissionOptions: &adminLevel,
|
|
}
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionOptions)
|
|
})
|
|
|
|
t.Run("DM participants may pin on a DM-scoped field", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
|
|
require.Nil(t, appErr)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
sysadminLevel := model.PermissionLevelSysadmin
|
|
|
|
t.Run("admin is honoured", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: dm.Id,
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &adminLevel,
|
|
PermissionOptions: &adminLevel,
|
|
}
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
})
|
|
|
|
t.Run("sysadmin is still refused", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: dm.Id,
|
|
PermissionField: &sysadminLevel,
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
CheckErrorID(t, err, "api.property_field.create.creator_cannot_edit.app_error")
|
|
})
|
|
})
|
|
|
|
t.Run("linked fields inherit the source template's permission levels", func(t *testing.T) {
|
|
// The App layer replaces a linked field's levels with the source
|
|
// template's, so the submitted levels never take effect. That is why
|
|
// linked fields are exempt from the anti-lockout check: validating the
|
|
// caller against a level that will be discarded would be meaningless.
|
|
//
|
|
// th.BasicUser must be a channel admin here: the anti-lockout check
|
|
// only runs for callers who may pin, so a plain member would exercise
|
|
// the silent-default path instead of the exemption.
|
|
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
|
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
t.Cleanup(func() {
|
|
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
|
})
|
|
th.LoginBasic(t)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
memberLevel := model.PermissionLevelMember
|
|
sysadminLevel := model.PermissionLevelSysadmin
|
|
|
|
// Templates are created through the App layer, which applies no pin
|
|
// logic, so the source's levels are exactly as specified here.
|
|
newTemplate := func(t *testing.T, field, values, options model.PermissionLevel) string {
|
|
t.Helper()
|
|
source, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: model.PropertyFieldObjectTypeTemplate,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PermissionField: &field,
|
|
PermissionValues: &values,
|
|
PermissionOptions: &options,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
return source.ID
|
|
}
|
|
|
|
t.Run("a pin the caller cannot satisfy is accepted, not refused", func(t *testing.T) {
|
|
// sysadmin is a level this channel admin cannot satisfy, so a 201
|
|
// here can only mean the anti-lockout check was skipped.
|
|
sourceID := newTemplate(t, sysadminLevel, sysadminLevel, sysadminLevel)
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
LinkedFieldID: &sourceID,
|
|
PermissionField: &sysadminLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
// The creator is locked out by design: they cannot edit or delete
|
|
// the field they just created.
|
|
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionOptions)
|
|
})
|
|
|
|
t.Run("every slot comes from the source, not the request", func(t *testing.T) {
|
|
// The three source levels are distinct from each other and each
|
|
// differs from what the request submits, so this fails if any slot
|
|
// is left as submitted, copied from the wrong slot, or collapsed to
|
|
// a single value.
|
|
sourceID := newTemplate(t, adminLevel, sysadminLevel, memberLevel)
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
LinkedFieldID: &sourceID,
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &adminLevel,
|
|
PermissionOptions: &sysadminLevel,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionOptions)
|
|
})
|
|
})
|
|
|
|
t.Run("admin can set permission level=creator on a post-object field", func(t *testing.T) {
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PermissionField: &creatorLevel,
|
|
PermissionValues: &creatorLevel,
|
|
PermissionOptions: &creatorLevel,
|
|
}
|
|
|
|
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.PermissionLevelCreator, *createdField.PermissionField)
|
|
require.NotNil(t, createdField.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelCreator, *createdField.PermissionValues)
|
|
require.NotNil(t, createdField.PermissionOptions)
|
|
require.Equal(t, model.PermissionLevelCreator, *createdField.PermissionOptions)
|
|
})
|
|
|
|
t.Run("permission level=creator on a user-object field should fail", func(t *testing.T) {
|
|
// User-object fields have no entity creator, so the level is rejected
|
|
// rather than silently resolving to admin.
|
|
creatorLevel := model.PermissionLevelCreator
|
|
memberLevel := model.PermissionLevelMember
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &creatorLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
|
|
_, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "user", field)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
t.Run("a non-scope-admin cannot pin the creator permission level", func(t *testing.T) {
|
|
// Only a caller who administers the field's scope may configure
|
|
// permission levels; everyone else has all three pinned to the object
|
|
// type's default, so a submitted creator level is discarded rather
|
|
// than honoured. th.BasicUser is a plain channel_user in
|
|
// th.BasicChannel here.
|
|
th.LoginBasic(t)
|
|
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PermissionField: &creatorLevel,
|
|
PermissionValues: &creatorLevel,
|
|
PermissionOptions: &creatorLevel,
|
|
}
|
|
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelMember, *created.PermissionOptions)
|
|
})
|
|
|
|
t.Run("channel admin can create an admin-managed field with creator-set values", func(t *testing.T) {
|
|
// A channel admin defines a channel-scoped field whose definition and
|
|
// options only the channel's admins may manage, and whose values each
|
|
// object's creator may set on their own object.
|
|
channelAdmin := th.CreateUser(t)
|
|
th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
|
|
_, appErr := th.App.AddUserToChannel(th.Context, channelAdmin, th.BasicChannel, false)
|
|
require.Nil(t, appErr)
|
|
_, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, channelAdmin.Id,
|
|
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
|
|
client := th.CreateClient()
|
|
_, _, err := client.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
|
|
require.NoError(t, err)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &creatorLevel,
|
|
PermissionOptions: &adminLevel,
|
|
}
|
|
|
|
created, resp, err := client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelCreator, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionOptions)
|
|
|
|
// Re-read: the pin happens before the store write, so echoing the
|
|
// request body back would look identical to a genuine persist.
|
|
fields, resp, err := client.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
var fetched *model.PropertyField
|
|
for _, f := range fields {
|
|
if f.ID == created.ID {
|
|
fetched = f
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, fetched, "the created field should be readable back")
|
|
require.Equal(t, model.PermissionLevelAdmin, *fetched.PermissionField)
|
|
require.Equal(t, model.PermissionLevelCreator, *fetched.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelAdmin, *fetched.PermissionOptions)
|
|
})
|
|
|
|
t.Run("system admin can create an admin-managed field with creator-set values", func(t *testing.T) {
|
|
// The same shape provisioned by a system admin: definition and options
|
|
// managed by the channel's admins, values settable by each object's
|
|
// creator.
|
|
adminLevel := model.PermissionLevelAdmin
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyFieldAttributeOptions: []map[string]any{
|
|
{"name": "confidential"},
|
|
{"name": "public"},
|
|
},
|
|
},
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &creatorLevel,
|
|
PermissionOptions: &adminLevel,
|
|
}
|
|
|
|
created, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
require.Equal(t, model.PermissionLevelCreator, *created.PermissionValues)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionOptions)
|
|
})
|
|
}
|
|
|
|
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)
|
|
|
|
// Hierarchical-scope fixtures live under a dedicated group so length
|
|
// assertions in those subtests can be exact — independent of any fields
|
|
// created by the unrelated subtests in `group` above.
|
|
hierGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_get_hier", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
require.NotNil(t, hierGroup)
|
|
|
|
mkField := func(t *testing.T, targetType model.PropertyFieldTargetLevel, targetID string) *model.PropertyField {
|
|
t.Helper()
|
|
f := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: hierGroup.ID,
|
|
ObjectType: "post",
|
|
TargetType: string(targetType),
|
|
TargetID: targetID,
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
created, cerr := th.App.CreatePropertyField(th.Context, f, false, "")
|
|
require.Nil(t, cerr)
|
|
return created
|
|
}
|
|
|
|
// Fixtures: 1 system, 1 team-A (BasicTeam), 1 team-B (other team),
|
|
// 2 channel-X (BasicChannel, in team-A), 1 channel-Y (in team-A).
|
|
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
|
|
channelY := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, th.BasicTeam.Id)
|
|
// BasicUser is *not* a member of channelY by default.
|
|
|
|
sysField := mkField(t, model.PropertyFieldTargetLevelSystem, "")
|
|
teamAField := mkField(t, model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id)
|
|
chanX1Field := mkField(t, model.PropertyFieldTargetLevelChannel, th.BasicChannel.Id)
|
|
chanX2Field := mkField(t, model.PropertyFieldTargetLevelChannel, th.BasicChannel.Id)
|
|
// Side-effect-only fixtures: their DB presence proves the hierarchical
|
|
// filter actively excludes them. ElementsMatch on the expected ID set
|
|
// handles the exclusion check.
|
|
_ = mkField(t, model.PropertyFieldTargetLevelTeam, otherTeam.Id) // team-B field
|
|
_ = mkField(t, model.PropertyFieldTargetLevelChannel, channelY.Id) // channel-Y field (BasicUser has no access)
|
|
|
|
// A dedicated group holding a single system-object-type field
|
|
// (ObjectType=system, TargetType=system). Used by the DWIM subtests
|
|
// to assert that requests with object_type=system collapse to the
|
|
// system scope regardless of any channel/team/target params passed.
|
|
systemObjGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_get_system_obj", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
sysObjField := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: systemObjGroup.ID,
|
|
ObjectType: model.PropertyFieldObjectTypeSystem,
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdSysObjField, appErr := th.App.CreatePropertyField(th.Context, sysObjField, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
fieldIDs := func(fields []*model.PropertyField) []string {
|
|
ids := make([]string, len(fields))
|
|
for i, f := range fields {
|
|
ids[i] = f.ID
|
|
}
|
|
return ids
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
t.Run("no scope (no target_type, channel_id or team_id) returns 400 scope_required", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("object_type=system without scope returns system-level rows", func(t *testing.T) {
|
|
// System-object-type fields can only live at the system scope, so
|
|
// the GET endpoint defaults to target_type=system when the caller
|
|
// omits the scope.
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), systemObjGroup.Name, model.PropertyFieldObjectTypeSystem, model.PropertyFieldSearch{PerPage: 60})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.ElementsMatch(t, []string{createdSysObjField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("object_type=system collapses to system scope regardless of channel_id (DWIM)", func(t *testing.T) {
|
|
// System-object fields can only live at the system scope by
|
|
// invariant, so the channel_id filter is a semantic no-op. The
|
|
// endpoint accepts the request and returns the same rows as the
|
|
// unscoped call rather than 400-ing on scope_conflict.
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), systemObjGroup.Name, model.PropertyFieldObjectTypeSystem, model.PropertyFieldSearch{
|
|
ChannelID: th.BasicChannel.Id,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.ElementsMatch(t, []string{createdSysObjField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("object_type=system collapses to system scope regardless of team_id (DWIM)", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), systemObjGroup.Name, model.PropertyFieldObjectTypeSystem, model.PropertyFieldSearch{
|
|
TeamID: th.BasicTeam.Id,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.ElementsMatch(t, []string{createdSysObjField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("object_type=system collapses to system scope regardless of target_type=channel (DWIM)", func(t *testing.T) {
|
|
// Even a confused single-target request like target_type=channel
|
|
// + target_id=<channel> is reduced to system scope when the
|
|
// object_type is system. The non-system filter values are
|
|
// dropped before the conflict check runs.
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), systemObjGroup.Name, model.PropertyFieldObjectTypeSystem, model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
|
TargetID: th.BasicChannel.Id,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.ElementsMatch(t, []string{createdSysObjField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("channel_id combined with target_type returns 400 scope_conflict", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: th.BasicChannel.Id,
|
|
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("team_id combined with target_id returns 400 scope_conflict", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TeamID: th.BasicTeam.Id,
|
|
TargetID: model.NewId(),
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("channel_id pointing at non-existent channel returns 403", func(t *testing.T) {
|
|
// Permission is checked before existence — non-existent channels are
|
|
// indistinguishable from inaccessible ones, by design.
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: model.NewId(),
|
|
})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("channel_id where user has no access returns 403", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
// BasicUser is not a member of channelY.
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: channelY.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("team_id where user has no access returns 403", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TeamID: otherTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("team_id alone returns system + team rows only", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TeamID: th.BasicTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Len(t, fields, 2, "should return exactly sysField + teamAField")
|
|
require.ElementsMatch(t, []string{sysField.ID, teamAField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("channel_id + team_id returns system + team + channel rows", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: th.BasicChannel.Id,
|
|
TeamID: th.BasicTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Len(t, fields, 4, "should return exactly sysField + teamAField + 2 channel-X fields")
|
|
require.ElementsMatch(t, []string{sysField.ID, teamAField.ID, chanX1Field.ID, chanX2Field.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("channel_id without team_id returns system + team + channel rows", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: th.BasicChannel.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Len(t, fields, 4, "should return exactly sysField + teamAField + 2 channel-X fields")
|
|
require.ElementsMatch(t, []string{sysField.ID, teamAField.ID, chanX1Field.ID, chanX2Field.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("DM channel returns system + channel rows (no team in the hierarchy)", func(t *testing.T) {
|
|
// DM channels have no parent team, so the hierarchy collapses
|
|
// to system → channel. teamAField must not leak in even though
|
|
// the BasicUser shares team-A with other channels.
|
|
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
|
|
dmField := mkField(t, model.PropertyFieldTargetLevelChannel, dmChannel.Id)
|
|
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: dmChannel.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.ElementsMatch(t, []string{sysField.ID, dmField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("GM channel returns system + channel rows (no team in the hierarchy)", 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)
|
|
gmField := mkField(t, model.PropertyFieldTargetLevelChannel, gmChannel.Id)
|
|
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: gmChannel.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.ElementsMatch(t, []string{sysField.ID, gmField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("a bad team_id is overwritten by the channel's team_id when channel_id is present and correctly returns system + team + channel rows", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
ChannelID: th.BasicChannel.Id,
|
|
TeamID: otherTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Len(t, fields, 4, "should return exactly sysField + teamAField + 2 channel-X fields")
|
|
require.ElementsMatch(t, []string{sysField.ID, teamAField.ID, chanX1Field.ID, chanX2Field.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("since=-1 is treated as no filter", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
SinceUpdateAt: -1,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Len(t, fields, 1, "should return exactly sysField — the only system row in hierGroup at this point")
|
|
require.ElementsMatch(t, []string{sysField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("since returns soft-deleted rows", func(t *testing.T) {
|
|
// Create a dedicated field, delete it, then verify it shows up in a since query.
|
|
toDelete := mkField(t, model.PropertyFieldTargetLevelSystem, "")
|
|
|
|
// Snapshot a since cutoff strictly less than UpdateAt of the delete.
|
|
// CreatePropertyField sets UpdateAt to CreateAt; use it - 1.
|
|
cutoff := toDelete.UpdateAt - 1
|
|
|
|
// Soft-delete via the App layer.
|
|
require.Nil(t, th.App.DeletePropertyField(th.Context, hierGroup.ID, toDelete.ID, false, ""))
|
|
|
|
// Use sysadmin to avoid any read-permission masking on tombstones.
|
|
fields, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TeamID: th.BasicTeam.Id,
|
|
SinceUpdateAt: cutoff,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
var found *model.PropertyField
|
|
for _, f := range fields {
|
|
if f.ID == toDelete.ID {
|
|
found = f
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, found, "soft-deleted field should be returned in since-delta")
|
|
require.Greater(t, found.DeleteAt, int64(0), "returned field should be tombstoned")
|
|
})
|
|
|
|
t.Run("cursor_create_at while since>0 returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
SinceUpdateAt: 1,
|
|
CursorID: model.NewId(),
|
|
CursorCreateAt: 12345,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("cursor_update_at while since absent returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
CursorID: model.NewId(),
|
|
CursorUpdateAt: 12345,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("cursor with both create_at and update_at set returns 400", func(t *testing.T) {
|
|
// Cursor.IsValid() requires exactly one of the two timestamps.
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
SinceUpdateAt: 1,
|
|
CursorID: model.NewId(),
|
|
CursorCreateAt: 12345,
|
|
CursorUpdateAt: 12345,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("target_id alone without scope returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TargetID: model.NewId(),
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("delta-mode cursor paginates correctly across multiple updates", func(t *testing.T) {
|
|
// Round-trip the dual-mode cursor: create N fresh fields after a
|
|
// cutoff, then page through them in delta mode with per_page < N.
|
|
// Use a dedicated team so the count is exactly predictable.
|
|
paginationTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
|
|
cutoff := model.GetMillis()
|
|
// CreatePropertyField stamps UpdateAt = GetMillis(); sleep to ensure
|
|
// all rows are strictly greater than `cutoff` even at ms precision.
|
|
time.Sleep(2 * time.Millisecond)
|
|
|
|
fresh := make([]*model.PropertyField, 0, 3)
|
|
for range 3 {
|
|
fresh = append(fresh, mkField(t, model.PropertyFieldTargetLevelTeam, paginationTeam.Id))
|
|
}
|
|
|
|
// Page 1: per_page=2, no cursor. Use sysadmin to side-step team membership.
|
|
page1, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TeamID: paginationTeam.Id,
|
|
SinceUpdateAt: cutoff,
|
|
PerPage: 2,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Len(t, page1, 2)
|
|
|
|
// Page 2: cursor from last of page 1, using update_at (delta mode).
|
|
last := page1[len(page1)-1]
|
|
page2, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TeamID: paginationTeam.Id,
|
|
SinceUpdateAt: cutoff,
|
|
CursorID: last.ID,
|
|
CursorUpdateAt: last.UpdateAt,
|
|
PerPage: 2,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// Combined pages should match the freshly-created set with no dups or skips.
|
|
combined := append(append([]string{}, fieldIDs(page1)...), fieldIDs(page2)...)
|
|
expected := []string{fresh[0].ID, fresh[1].ID, fresh[2].ID}
|
|
require.ElementsMatch(t, expected, combined, "delta-mode cursor must paginate the full set with no dups or skips")
|
|
})
|
|
|
|
t.Run("single-target target_type=team returns only that team's rows", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.GetPropertyFields(context.Background(), hierGroup.Name, "post", model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
|
TargetID: th.BasicTeam.Id,
|
|
PerPage: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Len(t, fields, 1, "should return exactly teamAField")
|
|
require.ElementsMatch(t, []string{teamAField.ID}, fieldIDs(fields))
|
|
})
|
|
}
|
|
|
|
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 TestSearchPropertyFields(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_search", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
require.NotNil(t, group)
|
|
|
|
memberLevel := model.PermissionLevelMember
|
|
mkField := func(t *testing.T, objectType string, targetType model.PropertyFieldTargetLevel, targetID string) *model.PropertyField {
|
|
t.Helper()
|
|
f := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: objectType,
|
|
TargetType: string(targetType),
|
|
TargetID: targetID,
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
created, cerr := th.App.CreatePropertyField(th.Context, f, false, "")
|
|
require.Nil(t, cerr)
|
|
return created
|
|
}
|
|
|
|
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
|
|
|
|
// Fixtures across multiple object types, all in team-A / channel-X scope.
|
|
postSysField := mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelSystem, "")
|
|
postTeamAField := mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id)
|
|
postChanXField := mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelChannel, th.BasicChannel.Id)
|
|
chanSysField := mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelSystem, "")
|
|
chanTeamAField := mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id)
|
|
userSysField := mkField(t, model.PropertyFieldObjectTypeUser, model.PropertyFieldTargetLevelSystem, "")
|
|
sysObjField := mkField(t, model.PropertyFieldObjectTypeSystem, model.PropertyFieldTargetLevelSystem, "")
|
|
// Out-of-scope rows that must NOT leak into hierarchical results.
|
|
_ = mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelTeam, otherTeam.Id) // team-B
|
|
_ = mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelTeam, otherTeam.Id)
|
|
|
|
fieldIDs := func(fields []*model.PropertyField) []string {
|
|
ids := make([]string, len(fields))
|
|
for i, f := range fields {
|
|
ids[i] = f.ID
|
|
}
|
|
return ids
|
|
}
|
|
|
|
t.Run("unauthenticated request should fail", func(t *testing.T) {
|
|
client := model.NewAPIv4Client(th.Client.URL)
|
|
_, resp, err := client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
TeamID: th.BasicTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckUnauthorizedStatus(t, resp)
|
|
})
|
|
|
|
t.Run("nonexistent group should return 404", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), "nonexistent_group", model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
TeamID: th.BasicTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("missing object_types returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
TeamID: th.BasicTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("invalid object_types returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{"garbage"},
|
|
TeamID: th.BasicTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("no scope (no target_type, channel_id or team_id) returns 400 scope_required", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("object_types=[system] without scope returns system-object rows", func(t *testing.T) {
|
|
// System-object fields can only live at the system scope, so the
|
|
// endpoint defaults to target_type=system when object_types is
|
|
// exactly [system]. This mirrors the GET endpoint's shortcut.
|
|
th.LoginBasic(t)
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypeSystem},
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Contains(t, fieldIDs(fields), sysObjField.ID)
|
|
})
|
|
|
|
t.Run("object_types=[system] collapses to system scope regardless of channel_id (DWIM)", func(t *testing.T) {
|
|
// Any channel/team/target filter is a semantic no-op when
|
|
// object_types is exactly [system]. The endpoint must return
|
|
// the same rows as the unscoped call rather than 400-ing.
|
|
th.LoginBasic(t)
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypeSystem},
|
|
ChannelID: th.BasicChannel.Id,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Contains(t, fieldIDs(fields), sysObjField.ID)
|
|
})
|
|
|
|
t.Run("object_types=[system, post] without scope returns 400 (shortcut requires exactly [system])", func(t *testing.T) {
|
|
// The system shortcut is only safe when every requested object
|
|
// type lives at the system scope. Mixing system with another
|
|
// object type without an explicit scope would silently drop the
|
|
// non-system rows under target_type=system, so we reject it.
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{
|
|
model.PropertyFieldObjectTypeSystem,
|
|
model.PropertyFieldObjectTypePost,
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("scope conflict (channel_id + target_type) returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
ChannelID: th.BasicChannel.Id,
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("team_id unauthorized returns 403", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
TeamID: otherTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("multi-OT hierarchical scope returns all matching rows across types", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{
|
|
model.PropertyFieldObjectTypePost,
|
|
model.PropertyFieldObjectTypeChannel,
|
|
model.PropertyFieldObjectTypeUser,
|
|
},
|
|
ChannelID: th.BasicChannel.Id,
|
|
TeamID: th.BasicTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// For each requested object_type, the hierarchical scope (system +
|
|
// team-A + channel-X) yields the matching rows:
|
|
// post -> postSysField, postTeamAField, postChanXField
|
|
// channel -> chanSysField, chanTeamAField
|
|
// user -> userSysField
|
|
expected := []string{
|
|
postSysField.ID, postTeamAField.ID, postChanXField.ID,
|
|
chanSysField.ID, chanTeamAField.ID,
|
|
userSysField.ID,
|
|
}
|
|
require.Len(t, fields, len(expected))
|
|
require.ElementsMatch(t, expected, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("DM channel returns multi-OT system + channel rows (no team in the hierarchy)", func(t *testing.T) {
|
|
// DM channels have no parent team — the hierarchy collapses to
|
|
// system → channel across every requested object_type.
|
|
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
|
|
dmPostField := mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelChannel, dmChannel.Id)
|
|
dmChannelField := mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelChannel, dmChannel.Id)
|
|
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{
|
|
model.PropertyFieldObjectTypePost,
|
|
model.PropertyFieldObjectTypeChannel,
|
|
model.PropertyFieldObjectTypeUser,
|
|
},
|
|
ChannelID: dmChannel.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// system rows for every requested OT + the DM-scoped fields. No
|
|
// team rows (postTeamAField / chanTeamAField) since the DM has
|
|
// no parent team.
|
|
require.ElementsMatch(t, []string{
|
|
postSysField.ID, chanSysField.ID, userSysField.ID,
|
|
dmPostField.ID, dmChannelField.ID,
|
|
}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("GM channel returns multi-OT system + channel rows (no team in the hierarchy)", 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)
|
|
gmChannelField := mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelChannel, gmChannel.Id)
|
|
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypeChannel, model.PropertyFieldObjectTypeUser},
|
|
ChannelID: gmChannel.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.ElementsMatch(t, []string{
|
|
chanSysField.ID, userSysField.ID, gmChannelField.ID,
|
|
}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("a bad team_id is overwritten by the channel's team_id when channel_id is present and correctly returns multi-OT rows", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{
|
|
model.PropertyFieldObjectTypePost,
|
|
model.PropertyFieldObjectTypeChannel,
|
|
model.PropertyFieldObjectTypeUser,
|
|
},
|
|
ChannelID: th.BasicChannel.Id,
|
|
TeamID: otherTeam.Id, // wrong team — server should ignore it
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// Exactly the same set as the "channel_id + team_id" case above, even
|
|
// though the request specified the wrong team_id.
|
|
expected := []string{
|
|
postSysField.ID, postTeamAField.ID, postChanXField.ID,
|
|
chanSysField.ID, chanTeamAField.ID,
|
|
userSysField.ID,
|
|
}
|
|
require.Len(t, fields, len(expected))
|
|
require.ElementsMatch(t, expected, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("single object_type behaves like singular endpoint", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypeChannel},
|
|
TeamID: th.BasicTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// team_id scope for object_type=channel: chanSysField + chanTeamAField.
|
|
require.ElementsMatch(t, []string{chanSysField.ID, chanTeamAField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("single-target scope returns only that exact slice", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost, model.PropertyFieldObjectTypeChannel},
|
|
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
|
TargetID: th.BasicTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// Single-target team-A across post + channel object types: only the
|
|
// team-A rows, no system or channel rows.
|
|
require.ElementsMatch(t, []string{postTeamAField.ID, chanTeamAField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("v1 group returns 404", func(t *testing.T) {
|
|
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_search_fields", Version: model.PropertyGroupVersionV1})
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, v1Group)
|
|
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), v1Group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
TeamID: th.BasicTeam.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("channel_id pointing at non-existent channel returns 403", func(t *testing.T) {
|
|
// Mirrors the singular endpoint: permission is checked first, so
|
|
// non-existent and inaccessible channels are indistinguishable.
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
ChannelID: model.NewId(),
|
|
})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("channel_id where user has no access returns 403", func(t *testing.T) {
|
|
// Channel BasicUser is not a member of.
|
|
inaccessibleChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, otherTeam.Id)
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
ChannelID: inaccessibleChannel.Id,
|
|
})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("target_id alone without scope returns 400", func(t *testing.T) {
|
|
// target_id with no target_type, channel_id or team_id is malformed.
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
|
TargetID: model.NewId(),
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("duplicate object_type values are idempotent", func(t *testing.T) {
|
|
// Sending the same object_type twice should not double-count rows.
|
|
th.LoginBasic(t)
|
|
fields, resp, err := th.Client.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost, model.PropertyFieldObjectTypePost},
|
|
ChannelID: th.BasicChannel.Id,
|
|
TeamID: th.BasicTeam.Id,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// Same set as a single-OT "post" query in hierarchical scope:
|
|
// postSysField + postTeamAField + postChanXField (no team-B / channel-Y).
|
|
require.ElementsMatch(t, []string{postSysField.ID, postTeamAField.ID, postChanXField.ID}, fieldIDs(fields))
|
|
})
|
|
|
|
t.Run("since returns soft-deleted rows across multiple object types", func(t *testing.T) {
|
|
// Create dedicated post and channel fields scoped to BasicTeam, delete
|
|
// them, then verify both tombstones surface in a search delta query.
|
|
postToDelete := mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id)
|
|
chanToDelete := mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id)
|
|
|
|
cutoff := min(postToDelete.UpdateAt, chanToDelete.UpdateAt) - 1
|
|
|
|
require.Nil(t, th.App.DeletePropertyField(th.Context, group.ID, postToDelete.ID, false, ""))
|
|
require.Nil(t, th.App.DeletePropertyField(th.Context, group.ID, chanToDelete.ID, false, ""))
|
|
|
|
// Sysadmin sidesteps any read-permission masking on tombstones.
|
|
fields, resp, err := th.SystemAdminClient.SearchPropertyFields(context.Background(), group.Name, model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost, model.PropertyFieldObjectTypeChannel},
|
|
TeamID: th.BasicTeam.Id,
|
|
SinceUpdateAt: cutoff,
|
|
PerPage: 200,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// Build a quick lookup keyed on ID.
|
|
byID := make(map[string]*model.PropertyField, len(fields))
|
|
for _, f := range fields {
|
|
byID[f.ID] = f
|
|
}
|
|
require.Contains(t, byID, postToDelete.ID, "post tombstone should be returned")
|
|
require.Contains(t, byID, chanToDelete.ID, "channel tombstone should be returned")
|
|
require.Greater(t, byID[postToDelete.ID].DeleteAt, int64(0))
|
|
require.Greater(t, byID[chanToDelete.ID].DeleteAt, int64(0))
|
|
})
|
|
|
|
t.Run("cursor pagination paginates a multi-OT scope without dups or skips", func(t *testing.T) {
|
|
// Create a dedicated team and a small set of rows under it across
|
|
// multiple object types, then page through in directory mode.
|
|
paginationTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
|
|
fresh := []*model.PropertyField{
|
|
mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelTeam, paginationTeam.Id),
|
|
mkField(t, model.PropertyFieldObjectTypePost, model.PropertyFieldTargetLevelTeam, paginationTeam.Id),
|
|
mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelTeam, paginationTeam.Id),
|
|
mkField(t, model.PropertyFieldObjectTypeChannel, model.PropertyFieldTargetLevelTeam, paginationTeam.Id),
|
|
}
|
|
|
|
searchBody := model.PropertyFieldSearch{
|
|
ObjectTypes: []string{model.PropertyFieldObjectTypePost, model.PropertyFieldObjectTypeChannel},
|
|
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
|
TargetID: paginationTeam.Id,
|
|
PerPage: 2,
|
|
}
|
|
|
|
page1, resp, err := th.SystemAdminClient.SearchPropertyFields(context.Background(), group.Name, searchBody)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Len(t, page1, 2)
|
|
|
|
last := page1[len(page1)-1]
|
|
searchBody.CursorID = last.ID
|
|
searchBody.CursorCreateAt = last.CreateAt
|
|
page2, resp, err := th.SystemAdminClient.SearchPropertyFields(context.Background(), group.Name, searchBody)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Len(t, page2, 2)
|
|
|
|
combined := append(append([]string{}, fieldIDs(page1)...), fieldIDs(page2)...)
|
|
expected := []string{fresh[0].ID, fresh[1].ID, fresh[2].ID, fresh[3].ID}
|
|
require.ElementsMatch(t, expected, combined, "search cursor pagination must cover the full set without dups or skips")
|
|
})
|
|
}
|
|
|
|
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("DM participant can manage options on a field pinned to admin", func(t *testing.T) {
|
|
// A DM has no channel-admin tier, so admin resolves to participation.
|
|
// This covers the options-only branch specifically: it dispatches to
|
|
// SessionHasPermissionToManagePropertyFieldOptions rather than the
|
|
// field-edit check (see isOptionsOnlyPatch in properties.go).
|
|
dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
|
|
require.Nil(t, appErr)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "channel",
|
|
TargetID: dm.Id,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyFieldAttributeOptions: []map[string]any{{"id": model.NewId(), "name": "first"}},
|
|
},
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &adminLevel,
|
|
PermissionOptions: &adminLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
optionsPatch := func() *model.PropertyFieldPatch {
|
|
return &model.PropertyFieldPatch{Attrs: &model.StringInterface{
|
|
model.PropertyFieldAttributeOptions: []map[string]any{{"id": model.NewId(), "name": model.NewId()}},
|
|
}}
|
|
}
|
|
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", field.ID, optionsPatch())
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
nonParticipant := th.CreateUser(t)
|
|
th.LinkUserToTeam(t, nonParticipant, th.BasicTeam)
|
|
nonParticipantClient := th.CreateClient()
|
|
_, _, err = nonParticipantClient.Login(context.Background(), nonParticipant.Email, nonParticipant.Password)
|
|
require.NoError(t, err)
|
|
|
|
_, resp, err = nonParticipantClient.PatchPropertyField(context.Background(), group.Name, "post", field.ID, optionsPatch())
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("patch cannot move a field to another scope", func(t *testing.T) {
|
|
// A field's scope decides who may administer it, so if patch could
|
|
// reparent a field, a channel admin could pin levels on a channel they
|
|
// administer and then move the result somewhere they do not. The
|
|
// handler defends this by discarding patch.TargetID/TargetType
|
|
// outright; nothing else enforces it, so assert it directly.
|
|
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
|
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
t.Cleanup(func() {
|
|
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
|
})
|
|
th.LoginBasic(t)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
created, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &adminLevel,
|
|
PermissionOptions: &adminLevel,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
require.Equal(t, model.PermissionLevelAdmin, *created.PermissionField)
|
|
|
|
// th.BasicChannel2 is a channel th.BasicUser belongs to but does not
|
|
// administer, which is the reparenting that would actually gain them
|
|
// something.
|
|
for name, patch := range map[string]*model.PropertyFieldPatch{
|
|
"to a channel they do not administer": {TargetID: model.NewPointer(th.BasicChannel2.Id)},
|
|
"to team scope": {TargetType: model.NewPointer("team"), TargetID: model.NewPointer(th.BasicTeam.Id)},
|
|
"to system scope": {TargetType: model.NewPointer("system"), TargetID: model.NewPointer("")},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
patched, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", created.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// The patch succeeds but the scope fields are ignored.
|
|
require.Equal(t, "channel", patched.TargetType)
|
|
require.Equal(t, th.BasicChannel.Id, patched.TargetID)
|
|
})
|
|
}
|
|
|
|
// Confirm the scope is unchanged in storage, not merely in the
|
|
// responses above.
|
|
stored, appErr := th.App.GetPropertyField(th.Context, group.ID, created.ID)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, "channel", stored.TargetType)
|
|
require.Equal(t, th.BasicChannel.Id, stored.TargetID)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
t.Run("field creator can patch their own field", func(t *testing.T) {
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
CreatedBy: th.BasicUser.Id,
|
|
PermissionField: &creatorLevel,
|
|
PermissionValues: &sysadminLevel,
|
|
PermissionOptions: &creatorLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
|
|
newName := model.NewId()
|
|
patched, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", field.ID, &model.PropertyFieldPatch{Name: &newName})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Equal(t, newName, patched.Name)
|
|
})
|
|
|
|
t.Run("non-creator member cannot patch a creator-gated field", func(t *testing.T) {
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
CreatedBy: th.BasicUser.Id,
|
|
PermissionField: &creatorLevel,
|
|
PermissionValues: &sysadminLevel,
|
|
PermissionOptions: &creatorLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic2(t)
|
|
|
|
newName := model.NewId()
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", field.ID, &model.PropertyFieldPatch{Name: &newName})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(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("DM participant can delete a field pinned to admin", func(t *testing.T) {
|
|
// Delete is gated on PermissionField, so a DM participant who pins
|
|
// admin must still be able to remove their own field — this is the
|
|
// delete-and-recreate recovery path for a mis-provisioned field.
|
|
dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
|
|
require.Nil(t, appErr)
|
|
|
|
adminLevel := model.PermissionLevelAdmin
|
|
newDMField := func(t *testing.T) *model.PropertyField {
|
|
t.Helper()
|
|
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "channel",
|
|
TargetID: dm.Id,
|
|
PermissionField: &adminLevel,
|
|
PermissionValues: &adminLevel,
|
|
PermissionOptions: &adminLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
return field
|
|
}
|
|
|
|
nonParticipant := th.CreateUser(t)
|
|
th.LinkUserToTeam(t, nonParticipant, th.BasicTeam)
|
|
nonParticipantClient := th.CreateClient()
|
|
_, _, err := nonParticipantClient.Login(context.Background(), nonParticipant.Email, nonParticipant.Password)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := nonParticipantClient.DeletePropertyField(context.Background(), group.Name, "post", newDMField(t).ID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
th.LoginBasic(t)
|
|
resp, err = th.Client.DeletePropertyField(context.Background(), group.Name, "post", newDMField(t).ID)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
t.Run("field creator can delete their own field", func(t *testing.T) {
|
|
// permission_field backs this handler as well as the patch handler, so
|
|
// the creator level covers deletion too. Intended, and pinned here so
|
|
// it is not mistaken for a bug and "fixed" later.
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
CreatedBy: th.BasicUser.Id,
|
|
PermissionField: &creatorLevel,
|
|
PermissionValues: &sysadminLevel,
|
|
PermissionOptions: &creatorLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
|
|
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", field.ID)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
t.Run("non-creator member cannot delete a creator-gated field", func(t *testing.T) {
|
|
creatorLevel := model.PermissionLevelCreator
|
|
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
CreatedBy: th.BasicUser.Id,
|
|
PermissionField: &creatorLevel,
|
|
PermissionValues: &sysadminLevel,
|
|
PermissionOptions: &creatorLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic2(t)
|
|
|
|
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", field.ID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(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)
|
|
})
|
|
|
|
t.Run("since", func(t *testing.T) {
|
|
// Dedicated post + field/value so we can move UpdateAt deterministically.
|
|
sincePost := th.CreatePost(t)
|
|
sinceTarget := sincePost.Id
|
|
|
|
sinceField := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdSinceField, appErr := th.App.CreatePropertyField(th.Context, sinceField, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
upserted, appErr := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
|
|
TargetID: sinceTarget,
|
|
TargetType: "post",
|
|
GroupID: group.ID,
|
|
FieldID: createdSinceField.ID,
|
|
Value: json.RawMessage(`"initial"`),
|
|
CreatedBy: th.BasicUser.Id,
|
|
UpdatedBy: th.BasicUser.Id,
|
|
}}, "", "", "")
|
|
require.Nil(t, appErr)
|
|
require.Len(t, upserted, 1)
|
|
sinceValue := upserted[0]
|
|
|
|
t.Run("since=-1 is treated as no filter", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", sinceTarget, model.PropertyValueSearch{
|
|
SinceUpdateAt: -1,
|
|
PerPage: 60,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.NotEmpty(t, values, "negative since should behave like no filter")
|
|
})
|
|
|
|
t.Run("since>0 returns only rows with UpdateAt > since", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
cutoff := model.GetMillis() + 10_000 // far in the future
|
|
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", sinceTarget, model.PropertyValueSearch{
|
|
SinceUpdateAt: cutoff,
|
|
PerPage: 60,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Empty(t, values, "since cutoff in the future should exclude all rows")
|
|
})
|
|
|
|
t.Run("since returns soft-deleted rows", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
// Snapshot a since cutoff strictly less than the upcoming tombstone UpdateAt.
|
|
cutoff := sinceValue.UpdateAt - 1
|
|
|
|
// Soft-delete the field through the app layer; this cascades to its values.
|
|
require.Nil(t, th.App.DeletePropertyField(th.Context, group.ID, createdSinceField.ID, false, ""))
|
|
|
|
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", sinceTarget, model.PropertyValueSearch{
|
|
SinceUpdateAt: cutoff,
|
|
PerPage: 60,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
var found *model.PropertyValue
|
|
for _, v := range values {
|
|
if v.ID == sinceValue.ID {
|
|
found = v
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, found, "soft-deleted value should be returned in since-delta")
|
|
require.Greater(t, found.DeleteAt, int64(0), "returned value should be tombstoned")
|
|
})
|
|
|
|
t.Run("cursor_create_at while since>0 returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", sinceTarget, model.PropertyValueSearch{
|
|
SinceUpdateAt: 1,
|
|
CursorID: model.NewId(),
|
|
CursorCreateAt: 12345,
|
|
PerPage: 60,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("cursor_update_at while since absent returns 400", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", sinceTarget, model.PropertyValueSearch{
|
|
CursorID: model.NewId(),
|
|
CursorUpdateAt: 12345,
|
|
PerPage: 60,
|
|
})
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestPatchPropertyValuesPostCreator(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.PostAttributes = true
|
|
}).InitBasic(t)
|
|
|
|
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_values_creator", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
|
|
creatorLevel := model.PermissionLevelCreator
|
|
memberLevel := model.PermissionLevelMember
|
|
sysadminLevel := model.PermissionLevelSysadmin
|
|
|
|
creatorField, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &sysadminLevel,
|
|
PermissionValues: &creatorLevel,
|
|
PermissionOptions: &sysadminLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
memberField, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &sysadminLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &sysadminLevel,
|
|
}, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
// th.BasicPost is authored by the team admin (InitBasic creates it before
|
|
// LoginBasic), who would pass through the admin arm and so prove nothing
|
|
// about the creator arm. Author an explicit post as BasicUser, who is a
|
|
// plain channel_user here. Both BasicUser and BasicUser2 are members of
|
|
// BasicChannel, so denials land on the creator check rather than on
|
|
// hasTargetAccess.
|
|
ownPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "authored by BasicUser",
|
|
}, th.BasicChannel, model.CreatePostFlags{})
|
|
require.Nil(t, appErr)
|
|
|
|
// A post BasicUser authored in a channel they have since left. They remain
|
|
// its creator forever, but lose create_post on the channel — so the outer
|
|
// hasTargetAccess gate rejects before the creator check is ever consulted.
|
|
leftChannel := th.CreatePublicChannel(t)
|
|
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, leftChannel, false)
|
|
require.Nil(t, appErr)
|
|
orphanPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
|
UserId: th.BasicUser.Id,
|
|
ChannelId: leftChannel.Id,
|
|
Message: "authored then left",
|
|
}, leftChannel, model.CreatePostFlags{})
|
|
require.Nil(t, appErr)
|
|
appErr = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", leftChannel)
|
|
require.Nil(t, appErr)
|
|
|
|
channelAdmin := th.CreateUser(t)
|
|
th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
|
|
_, appErr = th.App.AddUserToChannel(th.Context, channelAdmin, th.BasicChannel, false)
|
|
require.Nil(t, appErr)
|
|
_, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, channelAdmin.Id,
|
|
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
|
|
teamAdminInChannel := th.CreateUser(t)
|
|
th.LinkUserToTeam(t, teamAdminInChannel, th.BasicTeam)
|
|
_, appErr = th.App.AddUserToChannel(th.Context, teamAdminInChannel, th.BasicChannel, false)
|
|
require.Nil(t, appErr)
|
|
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdminInChannel.Id,
|
|
model.TeamUserRoleId+" "+model.TeamAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
|
|
teamAdminOutsideChannel := th.CreateUser(t)
|
|
th.LinkUserToTeam(t, teamAdminOutsideChannel, th.BasicTeam)
|
|
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdminOutsideChannel.Id,
|
|
model.TeamUserRoleId+" "+model.TeamAdminRoleId)
|
|
require.Nil(t, appErr)
|
|
|
|
// One client per caller so the table can name the caller directly instead
|
|
// of switching a shared client's session between cases.
|
|
login := func(u *model.User) *model.Client4 {
|
|
client := th.CreateClient()
|
|
_, _, err := client.Login(context.Background(), u.Email, u.Password)
|
|
require.NoError(t, err)
|
|
return client
|
|
}
|
|
|
|
authorClient := login(th.BasicUser)
|
|
nonAuthorClient := login(th.BasicUser2)
|
|
channelAdminClient := login(channelAdmin)
|
|
teamAdminInChannelClient := login(teamAdminInChannel)
|
|
teamAdminOutsideChannelClient := login(teamAdminOutsideChannel)
|
|
|
|
creatorItem := []model.PropertyValuePatchItem{
|
|
{FieldID: creatorField.ID, Value: json.RawMessage(`"tagged"`)},
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
client *model.Client4
|
|
targetID string
|
|
items []model.PropertyValuePatchItem
|
|
allowed bool
|
|
errorID string
|
|
}{
|
|
{
|
|
name: "author can set values on their own post",
|
|
client: authorClient,
|
|
targetID: ownPost.Id,
|
|
items: creatorItem,
|
|
allowed: true,
|
|
},
|
|
{
|
|
// Specifically the inner permission gate, not hasTargetAccess:
|
|
// BasicUser2 is a channel member and may post there.
|
|
name: "channel member who did not author the post is rejected by the values check",
|
|
client: nonAuthorClient,
|
|
targetID: ownPost.Id,
|
|
items: creatorItem,
|
|
allowed: false,
|
|
errorID: "api.property_value.patch.no_values_permission.app_error",
|
|
},
|
|
{
|
|
name: "channel admin can set values on someone else's post",
|
|
client: channelAdminClient,
|
|
targetID: ownPost.Id,
|
|
items: creatorItem,
|
|
allowed: true,
|
|
},
|
|
{
|
|
name: "team admin who is a channel member can set values on someone else's post",
|
|
client: teamAdminInChannelClient,
|
|
targetID: ownPost.Id,
|
|
items: creatorItem,
|
|
allowed: true,
|
|
},
|
|
{
|
|
// Rejected by hasTargetAccess, not by the creator check: without
|
|
// channel membership there is no create_post on the channel. The
|
|
// error id is what distinguishes the two gates.
|
|
name: "team admin who is not a channel member is rejected by the outer target gate",
|
|
client: teamAdminOutsideChannelClient,
|
|
targetID: ownPost.Id,
|
|
items: creatorItem,
|
|
allowed: false,
|
|
errorID: "api.context.permissions.app_error",
|
|
},
|
|
{
|
|
name: "system admin can set values on someone else's post",
|
|
client: th.SystemAdminClient,
|
|
targetID: ownPost.Id,
|
|
items: creatorItem,
|
|
allowed: true,
|
|
},
|
|
{
|
|
// creator narrows within hasTargetAccess, it does not replace it.
|
|
// No errorID: this is the outer gate's permission error, whose id
|
|
// depends on the channel type's write permission.
|
|
name: "author who left the channel is rejected by the outer target gate",
|
|
client: authorClient,
|
|
targetID: orphanPost.Id,
|
|
items: creatorItem,
|
|
allowed: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
values, resp, err := tc.client.PatchPropertyValues(context.Background(), group.Name, "post", tc.targetID, tc.items)
|
|
|
|
if !tc.allowed {
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
if tc.errorID != "" {
|
|
CheckErrorID(t, err, tc.errorID)
|
|
}
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Len(t, values, len(tc.items))
|
|
})
|
|
}
|
|
|
|
// Kept out of the table above: this asserts the absence of a side effect
|
|
// rather than a permission outcome, and it issues its own patch so it still
|
|
// holds when run in isolation.
|
|
t.Run("a denied item rejects the whole batch and writes nothing", func(t *testing.T) {
|
|
// The handler checks every item before any upsert, so a batch mixing an
|
|
// allowed member field with a denied creator field must not persist the
|
|
// allowed half.
|
|
items := []model.PropertyValuePatchItem{
|
|
{FieldID: memberField.ID, Value: json.RawMessage(`"allowed"`)},
|
|
{FieldID: creatorField.ID, Value: json.RawMessage(`"denied"`)},
|
|
}
|
|
_, resp, err := nonAuthorClient.PatchPropertyValues(context.Background(), group.Name, "post", ownPost.Id, items)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
|
|
|
|
values, resp, err := authorClient.GetPropertyValues(context.Background(), group.Name, "post", ownPost.Id, model.PropertyValueSearch{PerPage: 60})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
for _, v := range values {
|
|
require.NotEqual(t, memberField.ID, v.FieldID, "the allowed item of the rejected batch must not have been written")
|
|
}
|
|
})
|
|
|
|
t.Run("guest who authored the post can set values on it", func(t *testing.T) {
|
|
prevLicense := th.App.Srv().License()
|
|
prevGuestAccounts := *th.App.Config().GuestAccountsSettings.Enable
|
|
t.Cleanup(func() {
|
|
th.App.Srv().SetLicense(prevLicense)
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = prevGuestAccounts })
|
|
})
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true })
|
|
th.App.Srv().SetLicense(model.NewTestLicense())
|
|
|
|
id := model.NewId()
|
|
guestPassword := model.NewTestPassword()
|
|
guest, appErr := th.App.CreateGuest(th.Context, &model.User{
|
|
Email: "success+" + id + "@simulator.amazonses.com",
|
|
Username: "un_" + id,
|
|
Nickname: "nn_" + id,
|
|
Password: guestPassword,
|
|
EmailVerified: true,
|
|
})
|
|
require.Nil(t, appErr)
|
|
th.LinkUserToTeam(t, guest, th.BasicTeam)
|
|
th.AddUserToChannel(t, guest, th.BasicChannel)
|
|
require.True(t, guest.IsGuest(), "the fixture must be a guest, or this case proves nothing")
|
|
|
|
guestPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
|
UserId: guest.Id,
|
|
ChannelId: th.BasicChannel.Id,
|
|
Message: "authored by the guest",
|
|
}, th.BasicChannel, model.CreatePostFlags{})
|
|
require.Nil(t, appErr)
|
|
|
|
guestClient := th.CreateClient()
|
|
_, _, loginErr := guestClient.Login(context.Background(), guest.Email, guestPassword)
|
|
require.NoError(t, loginErr)
|
|
|
|
values, resp, err := guestClient.PatchPropertyValues(context.Background(), group.Name, "post", guestPost.Id, creatorItem)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Len(t, values, len(creatorItem))
|
|
|
|
// The grant is authorship, not guest-ness. ownPost is authored by
|
|
// BasicUser in the same channel, so this denial lands on the inner
|
|
// values check rather than on channel access — the error id is what
|
|
// separates the two gates.
|
|
_, resp, err = guestClient.PatchPropertyValues(context.Background(), group.Name, "post", ownPost.Id, creatorItem)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
|
|
})
|
|
}
|
|
|
|
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) {
|
|
fields, resp, getErr := th.Client.GetPropertyFields(context.Background(), group.Name, model.PropertyFieldObjectTypeSystem, model.PropertyFieldSearch{
|
|
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
|
})
|
|
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)
|
|
})
|
|
}
|