mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
4827 lines
177 KiB
Go
4827 lines
177 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/hanzoteam/server/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("invalid group name should fail", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "system",
|
|
}
|
|
|
|
// Invalid group name with uppercase — route regex won't match, returns 404
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), "Invalid", "post", field)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("nonexistent group name should fail", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "system",
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), "nonexistent_group", "post", field)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("successful creation should return 201", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
}
|
|
|
|
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
require.NotEmpty(t, createdField.ID)
|
|
require.Equal(t, field.Name, createdField.Name)
|
|
require.Equal(t, "post", createdField.ObjectType)
|
|
require.Equal(t, group.ID, createdField.GroupID)
|
|
require.Equal(t, th.BasicUser.Id, createdField.CreatedBy)
|
|
})
|
|
|
|
t.Run("websocket event should be fired on field creation", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
}
|
|
|
|
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
var receivedField model.PropertyField
|
|
require.Eventually(t, func() bool {
|
|
select {
|
|
case event := <-webSocketClient.EventChannel:
|
|
if event.EventType() == model.WebsocketEventPropertyFieldCreated {
|
|
fieldData, ok := event.GetData()["property_field"].(string)
|
|
require.True(t, ok)
|
|
require.NoError(t, json.Unmarshal([]byte(fieldData), &receivedField))
|
|
require.Equal(t, "post", event.GetData()["object_type"])
|
|
require.Equal(t, th.BasicChannel.Id, event.GetBroadcast().ChannelId)
|
|
return true
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
return false
|
|
}, 5*time.Second, 100*time.Millisecond)
|
|
|
|
require.Equal(t, createdField.ID, receivedField.ID)
|
|
require.Equal(t, createdField.Name, receivedField.Name)
|
|
})
|
|
|
|
t.Run("group_id in body should be overridden by group_name from URL", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
GroupID: model.NewId(), // Try to set a different group ID in body
|
|
}
|
|
|
|
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
|
|
// The group ID should come from the URL's group_name resolution, not the body
|
|
require.Equal(t, group.ID, createdField.GroupID)
|
|
})
|
|
|
|
t.Run("system target_type requires ManageSystem permission", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "system",
|
|
}
|
|
|
|
// Non-admin should be rejected for system-scoped fields
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin should succeed
|
|
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
require.Equal(t, "system", createdField.TargetType)
|
|
})
|
|
|
|
t.Run("channel target_type requires CreatePost permission", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: th.BasicChannel.Id,
|
|
}
|
|
|
|
// BasicUser is a member of BasicChannel and can post — should succeed
|
|
createdField, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
require.Equal(t, "channel", createdField.TargetType)
|
|
require.Equal(t, th.BasicChannel.Id, createdField.TargetID)
|
|
})
|
|
|
|
t.Run("channel target_type with inaccessible channel should fail", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
// Create a channel BasicUser is not a member of
|
|
otherTeam := th.CreateTeamWithClient(t, th.SystemAdminClient)
|
|
otherChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, otherTeam.Id)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
TargetID: otherChannel.Id,
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("channel target_type without target_id should fail", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "channel",
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("team target_type requires ManageTeam permission", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "team",
|
|
TargetID: th.BasicTeam.Id,
|
|
}
|
|
|
|
// BasicUser is a member but not a team admin — should fail
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// SystemAdmin should succeed
|
|
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.NoError(t, err)
|
|
CheckCreatedStatus(t, resp)
|
|
require.Equal(t, "team", createdField.TargetType)
|
|
require.Equal(t, th.BasicTeam.Id, createdField.TargetID)
|
|
})
|
|
|
|
t.Run("team target_type without target_id should fail", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "team",
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("unknown target_type should fail with bad request", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "unknown",
|
|
TargetID: model.NewId(),
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("empty target_type should fail with bad request", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("v1 group should return 404", func(t *testing.T) {
|
|
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_create", Version: model.PropertyGroupVersionV1})
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, v1Group)
|
|
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "system",
|
|
}
|
|
|
|
_, resp, err := th.Client.CreatePropertyField(context.Background(), v1Group.Name, "post", field)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
}
|
|
|
|
func TestGetPropertyFields(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.IntegratedBoards = true
|
|
}).InitBasic(t)
|
|
|
|
// Register property groups for testing
|
|
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_get", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
require.NotNil(t, group)
|
|
|
|
otherGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_get_other", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
require.NotNil(t, otherGroup)
|
|
|
|
memberLevel := model.PermissionLevelMember
|
|
|
|
// Create a field in the main group
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, createdField)
|
|
|
|
// Create a field in a different group
|
|
otherField := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: otherGroup.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdOtherField, appErr := th.App.CreatePropertyField(th.Context, otherField, false, "")
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, createdOtherField)
|
|
|
|
// 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("protected field update should fail", func(t *testing.T) {
|
|
protectedField := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Protected: true,
|
|
PermissionField: &noneLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdProtectedField, appErr := th.App.CreatePropertyField(th.Context, protectedField, true, "")
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, createdProtectedField)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdProtectedField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("object type mismatch should fail", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
// Try to update with wrong object_type in URL. Expected 404 to match
|
|
// the shape of a non-existent field.
|
|
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "channel", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("patch with wrong group name should fail 404", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
// Try to patch using the other group's name — field belongs to `group`, not `otherGroup`.
|
|
// A field not found because of a wrong group must surface as 404, not a generic 500.
|
|
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), otherGroup.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
|
|
})
|
|
|
|
t.Run("options-only update should check options permission", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &sysadminLevel, // Only admin can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, createdField)
|
|
|
|
// Try to update options as a non-admin
|
|
th.LoginBasic(t)
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "New Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin should be able to update options
|
|
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
t.Run("options-only update on non-select field should check field permission", func(t *testing.T) {
|
|
// A text field with options in attrs should NOT use the options permission path
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel, // Member can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, createdField)
|
|
|
|
// Non-admin patches only options on a text field — should require field permission, not options
|
|
th.LoginBasic(t)
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin should succeed (has field permission)
|
|
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
t.Run("options-only update on select field with member options permission should succeed", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel, // Member can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
newOptionID := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": newOptionID, "name": "New Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.NotNil(t, updatedField)
|
|
|
|
// Verify the patched option was actually stored, not just that the request succeeded.
|
|
opts := updatedField.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
|
require.Len(t, opts, 1)
|
|
option := opts[0].(map[string]any)
|
|
require.Equal(t, newOptionID, option["id"])
|
|
require.Equal(t, "New Option", option["name"])
|
|
})
|
|
|
|
t.Run("options-only update on multiselect field with member options permission should succeed", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeMultiselect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel, // Member can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
newOptionID := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": newOptionID, "name": "New Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.NotNil(t, updatedField)
|
|
|
|
// Verify the patched option was actually stored, not just that the request succeeded.
|
|
opts := updatedField.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
|
require.Len(t, opts, 1)
|
|
option := opts[0].(map[string]any)
|
|
require.Equal(t, newOptionID, option["id"])
|
|
require.Equal(t, "New Option", option["name"])
|
|
})
|
|
|
|
t.Run("options-only update on rank field with member options permission should succeed", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeRank,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1", "rank": 1},
|
|
},
|
|
},
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel, // Member can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
// A member may re-rank options: options-only patches on rank fields
|
|
// use the narrower manage-options permission, same as select/multiselect.
|
|
th.LoginBasic(t)
|
|
newOptionID := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": newOptionID, "name": "New Option", "rank": 2},
|
|
},
|
|
},
|
|
}
|
|
|
|
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.NotNil(t, updatedField)
|
|
|
|
// Verify the patched option (including its rank) was actually stored.
|
|
opts := updatedField.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
|
require.Len(t, opts, 1)
|
|
option := opts[0].(map[string]any)
|
|
require.Equal(t, newOptionID, option["id"])
|
|
require.Equal(t, "New Option", option["name"])
|
|
require.EqualValues(t, 2, option["rank"])
|
|
})
|
|
|
|
t.Run("name and options update on rank field should check field permission not options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeRank,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1", "rank": 1},
|
|
},
|
|
},
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel, // Member can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Name: &newName,
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "New Option", "rank": 2},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Member fails — a structural change (name) on a rank field requires the
|
|
// full edit-field permission, not the narrower options permission.
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin (has field permission) succeeds.
|
|
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Equal(t, newName, updatedField.Name)
|
|
})
|
|
|
|
t.Run("options-only update on select field with none options permission should fail for all", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &noneLevel, // Nobody can manage options via permission
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "New Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Non-admin should fail
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin should also fail
|
|
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("name and options update on select field should check field permission not options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel, // Member can manage options
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Name: &newName,
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "New Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Non-admin should fail — name change requires field permission (sysadmin)
|
|
th.LoginBasic(t)
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin should succeed
|
|
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Equal(t, newName, updatedField.Name)
|
|
})
|
|
|
|
t.Run("field update should check field permission", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &sysadminLevel, // Only admin can edit field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, createdField)
|
|
|
|
// Try to update name as a non-admin
|
|
th.LoginBasic(t)
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
|
|
// Admin should be able to update name
|
|
_, resp, err = th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
t.Run("successful update should return updated field", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Equal(t, newName, updatedField.Name)
|
|
require.Equal(t, th.BasicUser.Id, updatedField.UpdatedBy)
|
|
})
|
|
|
|
t.Run("websocket event should be fired on field update", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
var receivedField model.PropertyField
|
|
require.Eventually(t, func() bool {
|
|
select {
|
|
case event := <-webSocketClient.EventChannel:
|
|
if event.EventType() == model.WebsocketEventPropertyFieldUpdated {
|
|
fieldData, ok := event.GetData()["property_field"].(string)
|
|
require.True(t, ok)
|
|
require.NoError(t, json.Unmarshal([]byte(fieldData), &receivedField))
|
|
require.Equal(t, "post", event.GetData()["object_type"])
|
|
// system-scoped field: no team or channel in broadcast
|
|
require.Empty(t, event.GetBroadcast().TeamId)
|
|
require.Empty(t, event.GetBroadcast().ChannelId)
|
|
return true
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
return false
|
|
}, 5*time.Second, 100*time.Millisecond)
|
|
|
|
require.Equal(t, updatedField.ID, receivedField.ID)
|
|
require.Equal(t, newName, receivedField.Name)
|
|
})
|
|
|
|
t.Run("target_id in patch should be silently ignored", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginSystemAdmin(t)
|
|
newName := model.NewId()
|
|
newTargetID := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Name: &newName,
|
|
TargetID: &newTargetID,
|
|
}
|
|
|
|
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Equal(t, newName, updatedField.Name)
|
|
require.Equal(t, createdField.TargetID, updatedField.TargetID)
|
|
})
|
|
|
|
t.Run("target_type in patch should be silently ignored", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginSystemAdmin(t)
|
|
newName := model.NewId()
|
|
newTargetType := "channel"
|
|
patch := &model.PropertyFieldPatch{
|
|
Name: &newName,
|
|
TargetType: &newTargetType,
|
|
}
|
|
|
|
updatedField, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Equal(t, newName, updatedField.Name)
|
|
require.Equal(t, "system", updatedField.TargetType)
|
|
})
|
|
|
|
t.Run("options-only patch should preserve other attrs keys via merge semantics", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Attrs: model.StringInterface{
|
|
"subtype": "color",
|
|
"options": []map[string]any{
|
|
{"id": model.NewId(), "name": "Option 1"},
|
|
},
|
|
},
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
newOptionID := model.NewId()
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []map[string]any{
|
|
{"id": newOptionID, "name": "New Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
updatedField, resp, err := th.Client.PatchPropertyField(context.Background(), group.Name, "post", createdField.ID, patch)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
// The "subtype" key should be preserved even though only "options" was patched
|
|
require.Equal(t, "color", updatedField.Attrs["subtype"])
|
|
// The "options" key should be updated
|
|
require.NotNil(t, updatedField.Attrs["options"])
|
|
})
|
|
|
|
t.Run("v1 group should return 404", func(t *testing.T) {
|
|
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_patch_field", Version: model.PropertyGroupVersionV1})
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, v1Group)
|
|
|
|
newName := model.NewId()
|
|
patch := &model.PropertyFieldPatch{Name: &newName}
|
|
|
|
_, resp, err := th.Client.PatchPropertyField(context.Background(), v1Group.Name, "post", model.NewId(), patch)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
}
|
|
|
|
func TestDeletePropertyField(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.IntegratedBoards = true
|
|
}).InitBasic(t)
|
|
|
|
// Register property groups for testing
|
|
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_delete", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
require.NotNil(t, group)
|
|
|
|
otherGroup, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_properties_delete_other", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
require.NotNil(t, otherGroup)
|
|
|
|
noneLevel := model.PermissionLevelNone
|
|
memberLevel := model.PermissionLevelMember
|
|
sysadminLevel := model.PermissionLevelSysadmin
|
|
|
|
t.Run("unauthenticated request should fail", func(t *testing.T) {
|
|
client := model.NewAPIv4Client(th.Client.URL)
|
|
|
|
resp, err := client.DeletePropertyField(context.Background(), group.Name, "post", model.NewId())
|
|
require.Error(t, err)
|
|
CheckUnauthorizedStatus(t, resp)
|
|
})
|
|
|
|
t.Run("protected field delete should fail", func(t *testing.T) {
|
|
protectedField := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
Protected: true,
|
|
PermissionField: &noneLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdProtectedField, appErr := th.App.CreatePropertyField(th.Context, protectedField, true, "")
|
|
require.Nil(t, appErr)
|
|
|
|
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "post", createdProtectedField.ID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("object type mismatch should fail", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
// Try to delete with wrong object_type in URL. Expected 404 to match
|
|
// the shape of a non-existent field.
|
|
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "channel", createdField.ID)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("delete with wrong group name should fail 404", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
// Try to delete using the other group's name — field belongs to `group`, not `otherGroup`.
|
|
// A field not found because of a wrong group must surface as 404, not a generic 500.
|
|
th.LoginSystemAdmin(t)
|
|
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), otherGroup.Name, "post", createdField.ID)
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
|
|
})
|
|
|
|
t.Run("user without permission should not be able to delete", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &sysadminLevel, // Only admin can edit/delete field
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", createdField.ID)
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("successful delete should return 200", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", createdField.ID)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
t.Run("websocket event should be fired on field deletion", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
th.LoginBasic(t)
|
|
webSocketClient := th.CreateConnectedWebSocketClient(t)
|
|
|
|
resp, err := th.Client.DeletePropertyField(context.Background(), group.Name, "post", createdField.ID)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
require.Eventually(t, func() bool {
|
|
select {
|
|
case event := <-webSocketClient.EventChannel:
|
|
if event.EventType() == model.WebsocketEventPropertyFieldDeleted {
|
|
require.Equal(t, createdField.ID, event.GetData()["field_id"])
|
|
require.Equal(t, "post", event.GetData()["object_type"])
|
|
// system-scoped field: no team or channel in broadcast
|
|
require.Empty(t, event.GetBroadcast().TeamId)
|
|
require.Empty(t, event.GetBroadcast().ChannelId)
|
|
return true
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
return false
|
|
}, 5*time.Second, 100*time.Millisecond)
|
|
})
|
|
|
|
t.Run("v1 group should return 404", func(t *testing.T) {
|
|
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_delete_field", Version: model.PropertyGroupVersionV1})
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, v1Group)
|
|
|
|
resp, err := th.Client.DeletePropertyField(context.Background(), v1Group.Name, "post", model.NewId())
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
}
|
|
|
|
func TestIsOptionsOnlyPatch(t *testing.T) {
|
|
t.Run("nil attrs is not options-only", func(t *testing.T) {
|
|
patch := &model.PropertyFieldPatch{
|
|
Name: new("new name"),
|
|
}
|
|
require.False(t, isOptionsOnlyPatch(patch))
|
|
})
|
|
|
|
t.Run("empty attrs is not options-only", func(t *testing.T) {
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{},
|
|
}
|
|
require.False(t, isOptionsOnlyPatch(patch))
|
|
})
|
|
|
|
t.Run("attrs with only options is options-only", func(t *testing.T) {
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []any{},
|
|
},
|
|
}
|
|
require.True(t, isOptionsOnlyPatch(patch))
|
|
})
|
|
|
|
t.Run("attrs with options and other keys is not options-only", func(t *testing.T) {
|
|
patch := &model.PropertyFieldPatch{
|
|
Attrs: &model.StringInterface{
|
|
"options": []any{},
|
|
"other": "value",
|
|
},
|
|
}
|
|
require.False(t, isOptionsOnlyPatch(patch))
|
|
})
|
|
|
|
t.Run("name change with options is not options-only", func(t *testing.T) {
|
|
patch := &model.PropertyFieldPatch{
|
|
Name: new("new name"),
|
|
Attrs: &model.StringInterface{
|
|
"options": []any{},
|
|
},
|
|
}
|
|
require.False(t, isOptionsOnlyPatch(patch))
|
|
})
|
|
|
|
t.Run("type change is not options-only", func(t *testing.T) {
|
|
newType := model.PropertyFieldTypeSelect
|
|
patch := &model.PropertyFieldPatch{
|
|
Type: &newType,
|
|
}
|
|
require.False(t, isOptionsOnlyPatch(patch))
|
|
})
|
|
}
|
|
|
|
func TestGetPropertyValues(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.FeatureFlags.IntegratedBoards = true
|
|
}).InitBasic(t)
|
|
|
|
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_values_get", Version: model.PropertyGroupVersionV2})
|
|
require.Nil(t, err)
|
|
|
|
memberLevel := model.PermissionLevelMember
|
|
|
|
// Create a field
|
|
field := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
// Use a real post as the target so target access checks pass
|
|
targetID := th.BasicPost.Id
|
|
|
|
// Create a value via upsert
|
|
value := &model.PropertyValue{
|
|
TargetID: targetID,
|
|
TargetType: "post",
|
|
GroupID: group.ID,
|
|
FieldID: createdField.ID,
|
|
Value: json.RawMessage(`"hello"`),
|
|
CreatedBy: th.BasicUser.Id,
|
|
UpdatedBy: th.BasicUser.Id,
|
|
}
|
|
_, appErr2 := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{value}, "", "", "")
|
|
require.Nil(t, appErr2)
|
|
|
|
t.Run("unauthenticated request should fail", func(t *testing.T) {
|
|
client := model.NewAPIv4Client(th.Client.URL)
|
|
|
|
_, resp, err := client.GetPropertyValues(context.Background(), group.Name, "post", targetID, model.PropertyValueSearch{PerPage: 60})
|
|
require.Error(t, err)
|
|
CheckUnauthorizedStatus(t, resp)
|
|
})
|
|
|
|
t.Run("successful get should return values", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", targetID, model.PropertyValueSearch{PerPage: 60})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.NotEmpty(t, values)
|
|
|
|
found := false
|
|
for _, v := range values {
|
|
if v.FieldID == createdField.ID {
|
|
found = true
|
|
require.Equal(t, json.RawMessage(`"hello"`), v.Value)
|
|
break
|
|
}
|
|
}
|
|
require.True(t, found, "Created value should be in the response")
|
|
})
|
|
|
|
t.Run("nonexistent group should fail", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
_, resp, err := th.Client.GetPropertyValues(context.Background(), "nonexistent_group", "post", targetID, model.PropertyValueSearch{PerPage: 60})
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("invalid target_id should not match route", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
// The route regex [A-Za-z0-9]+ rejects IDs with invalid characters
|
|
_, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", "bad-id", model.PropertyValueSearch{PerPage: 60})
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
t.Run("get with no values returns empty array", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
emptyPost := th.CreatePost(t)
|
|
values, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", emptyPost.Id, model.PropertyValueSearch{PerPage: 60})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Empty(t, values)
|
|
})
|
|
|
|
t.Run("cursor pagination should return subsequent pages", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
// Create a real post and additional fields/values for pagination
|
|
paginationPost := th.CreatePost(t)
|
|
paginationTarget := paginationPost.Id
|
|
for range 4 {
|
|
f := &model.PropertyField{
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
GroupID: group.ID,
|
|
ObjectType: "post",
|
|
TargetType: "system",
|
|
PermissionField: &memberLevel,
|
|
PermissionValues: &memberLevel,
|
|
PermissionOptions: &memberLevel,
|
|
}
|
|
cf, appErr := th.App.CreatePropertyField(th.Context, f, false, "")
|
|
require.Nil(t, appErr)
|
|
|
|
_, appErr2 := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
|
|
TargetID: paginationTarget,
|
|
TargetType: "post",
|
|
GroupID: group.ID,
|
|
FieldID: cf.ID,
|
|
Value: json.RawMessage(`"val"`),
|
|
CreatedBy: th.BasicUser.Id,
|
|
UpdatedBy: th.BasicUser.Id,
|
|
}}, "", "", "")
|
|
require.Nil(t, appErr2)
|
|
}
|
|
|
|
// First page
|
|
page0, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", paginationTarget, model.PropertyValueSearch{PerPage: 2})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.Len(t, page0, 2)
|
|
|
|
// Second page using cursor from last item
|
|
last := page0[len(page0)-1]
|
|
page1, resp, err := th.Client.GetPropertyValues(context.Background(), group.Name, "post", paginationTarget, model.PropertyValueSearch{
|
|
PerPage: 2,
|
|
CursorID: last.ID,
|
|
CursorCreateAt: last.CreateAt,
|
|
})
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
require.NotEmpty(t, page1)
|
|
|
|
// Pages should not overlap
|
|
page0IDs := map[string]bool{}
|
|
for _, v := range page0 {
|
|
page0IDs[v.ID] = true
|
|
}
|
|
for _, v := range page1 {
|
|
require.False(t, page0IDs[v.ID], "Second page should not contain values from first page")
|
|
}
|
|
})
|
|
|
|
t.Run("v1 group should return 404", func(t *testing.T) {
|
|
v1Group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_v1_get_values", Version: model.PropertyGroupVersionV1})
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, v1Group)
|
|
|
|
_, resp, err := th.Client.GetPropertyValues(context.Background(), v1Group.Name, "post", th.BasicPost.Id, model.PropertyValueSearch{PerPage: 60})
|
|
require.Error(t, err)
|
|
CheckNotFoundStatus(t, resp)
|
|
})
|
|
|
|
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 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)
|
|
})
|
|
}
|