Files
zeekay 8feb24ea1f One name for this fork, and it is ours
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.
2026-08-28 23:35:36 -07:00

1590 lines
58 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"net/http"
"testing"
"github.com/hanzoteam/server/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHooksOnlyScopeToManagedGroups(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
// Operations on an unmanaged group should bypass the access control
// hook entirely and proceed directly to the store layer.
unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_group", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
t.Run("CreatePropertyField on unmanaged group bypasses hooks", func(t *testing.T) {
field := &model.PropertyField{
GroupID: unmanagedGroup.ID,
Name: "test-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
})
t.Run("GetPropertyField on unmanaged group bypasses hooks", func(t *testing.T) {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: unmanagedGroup.ID,
Name: "get-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
result, err := th.service.GetPropertyField(rctx, unmanagedGroup.ID, field.ID)
require.NoError(t, err)
assert.Equal(t, field.ID, result.ID)
})
}
func TestCountActivePropertyFieldsForGroup(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
t.Run("should return count of active property fields for a group", func(t *testing.T) {
groupID := model.NewId()
// Create some property fields
for range 5 {
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Property " + model.NewId(),
})
}
count, err := th.service.CountActivePropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
assert.Equal(t, int64(5), count)
})
t.Run("should return 0 for empty group", func(t *testing.T) {
groupID := model.NewId()
count, err := th.service.CountActivePropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
})
t.Run("should not count deleted fields", func(t *testing.T) {
groupID := model.NewId()
// Create 3 fields
for range 3 {
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Active " + model.NewId(),
})
}
// Create and delete 2 fields
for range 2 {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Deleted " + model.NewId(),
})
err := th.dbStore.PropertyField().Delete(groupID, field.ID)
require.NoError(t, err)
}
count, err := th.service.CountActivePropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
})
}
func TestCountAllPropertyFieldsForGroup(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
t.Run("should return count of all property fields including deleted", func(t *testing.T) {
groupID := model.NewId()
// Create 5 active fields
for range 5 {
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Active " + model.NewId(),
})
}
// Create and delete 3 fields
for range 3 {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Deleted " + model.NewId(),
})
err := th.dbStore.PropertyField().Delete(groupID, field.ID)
require.NoError(t, err)
}
count, err := th.service.CountAllPropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
assert.Equal(t, int64(8), count)
})
t.Run("should return 0 for empty group", func(t *testing.T) {
groupID := model.NewId()
count, err := th.service.CountAllPropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
})
t.Run("should return higher count than active fields when there are deleted fields", func(t *testing.T) {
groupID := model.NewId()
// Create 5 active fields
for range 5 {
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Active " + model.NewId(),
})
}
// Create and delete 3 fields
for range 3 {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Deleted " + model.NewId(),
})
err := th.dbStore.PropertyField().Delete(groupID, field.ID)
require.NoError(t, err)
}
activeCount, err := th.service.CountActivePropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
allCount, err := th.service.CountAllPropertyFieldsForGroup(rctx, groupID)
require.NoError(t, err)
assert.Equal(t, int64(5), activeCount)
assert.Equal(t, int64(8), allCount)
assert.True(t, allCount > activeCount)
})
}
func TestCreatePropertyField(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
t.Run("legacy property with empty ObjectType should skip conflict check", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1)
field := &model.PropertyField{
ObjectType: "", // Legacy
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Legacy Property",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
assert.Equal(t, "Legacy Property", result.Name)
})
t.Run("system-level property with no conflict should create successfully", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "System Property",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
assert.Equal(t, "System Property", result.Name)
})
t.Run("system-level property with existing team property should conflict", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
// Create team-level property first (direct to avoid conflict check)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "Status",
})
// Try to create system-level property with same name
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Status",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "team-level")
})
t.Run("system-level property with existing channel property should conflict", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
channel := th.CreateChannel(t, team.Id)
// Create channel-level property first (direct to avoid conflict check)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel.Id,
Type: model.PropertyFieldTypeText,
Name: "Priority",
})
// Try to create system-level property with same name
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "Priority",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "channel-level")
})
t.Run("team-level property with no conflict should create successfully", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "Team Property",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
assert.Equal(t, "Team Property", result.Name)
})
t.Run("team-level property with existing system property should conflict", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
// Create system-level property first (direct to avoid conflict check)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "SystemField",
})
// Try to create team-level property with same name
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "SystemField",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "system-level")
})
t.Run("team-level property with existing channel property in same team should conflict", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
channel := th.CreateChannel(t, team.Id)
// Create channel-level property first (direct to avoid conflict check)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel.Id,
Type: model.PropertyFieldTypeText,
Name: "ChannelProp",
})
// Try to create team-level property with same name
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "ChannelProp",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "channel-level")
})
t.Run("channel-level property with no conflict should create successfully", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
channel := th.CreateChannel(t, team.Id)
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel.Id,
Type: model.PropertyFieldTypeText,
Name: "Channel Property",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
assert.Equal(t, "Channel Property", result.Name)
})
t.Run("channel-level property with existing system property should conflict", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
channel := th.CreateChannel(t, team.Id)
// Create system-level property first (direct to avoid conflict check)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "GlobalProp",
})
// Try to create channel-level property with same name
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel.Id,
Type: model.PropertyFieldTypeText,
Name: "GlobalProp",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "system-level")
})
t.Run("channel-level property with existing team property should conflict", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
channel := th.CreateChannel(t, team.Id)
// Create team-level property first (direct to avoid conflict check)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "TeamProp",
})
// Try to create channel-level property with same name
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel.Id,
Type: model.PropertyFieldTypeText,
Name: "TeamProp",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "team-level")
})
t.Run("DM channel only checks system-level for conflicts", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team := th.CreateTeam(t)
dmChannel := th.CreateDMChannel(t)
// Create a team-level property in a team
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "TeamOnlyProp",
})
// DM channel property should not conflict with team-level property
// since DM channels have no team association
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: dmChannel.Id,
Type: model.PropertyFieldTypeText,
Name: "TeamOnlyProp",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
})
t.Run("channel in different team does not conflict with team property", func(t *testing.T) {
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
channelInTeam2 := th.CreateChannel(t, team2.Id)
// Create team-level property in team1
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team1.Id,
Type: model.PropertyFieldTypeText,
Name: "Team1Prop",
})
// Channel-level property in team2 should not conflict with team1's property
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group.ID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channelInTeam2.Id,
Type: model.PropertyFieldTypeText,
Name: "Team1Prop",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
})
t.Run("properties in different groups with same name do not conflict", func(t *testing.T) {
group1 := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
group2 := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
// Create system-level property in group1
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: group1,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "SharedName",
})
// System-level property in group2 should not conflict
field := &model.PropertyField{
ObjectType: "channel",
GroupID: group2,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "SharedName",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
})
t.Run("deleted properties do not cause conflicts", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
// Create and delete a system-level property
deleted := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "DeletedProp",
})
err := th.dbStore.PropertyField().Delete(groupID, deleted.ID)
require.NoError(t, err)
// New property with same name should succeed
field := &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "DeletedProp",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotEmpty(t, result.ID)
})
}
func TestUpdatePropertyField(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
t.Run("updating non-name fields should not trigger conflict check", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
// Create a property
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "NoConflictCheck",
Attrs: map[string]any{
"key": "original",
},
})
// Update non-name fields (Attrs only)
field.Attrs = map[string]any{
"key": "updated",
}
result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "updated", result.Attrs["key"])
})
t.Run("updating name to non-conflicting value should succeed", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
// Create a property
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "OriginalName",
})
// Update name to non-conflicting value
field.Name = "NewUniqueName"
result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "NewUniqueName", result.Name)
})
t.Run("updating name to conflicting value at team level should fail", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
team := th.CreateTeam(t)
// Create a team-level property
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "ExistingTeamProp",
})
// Create a system-level property with different name
systemField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "SystemProp",
})
// Try to update system-level to name that conflicts with team-level
systemField.Name = "ExistingTeamProp"
result, _, err := th.service.UpdatePropertyField(rctx, groupID, systemField)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "team-level")
})
t.Run("updating DM channel property to same name as regular channel property should succeed", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
team := th.CreateTeam(t)
channel := th.CreateChannel(t, team.Id)
dmChannel := th.CreateDMChannel(t)
// Create a channel-level property in a regular channel
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel.Id,
Type: model.PropertyFieldTypeText,
Name: "ChannelProp",
})
// Create a channel-level property in a DM channel with different name
dmField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: dmChannel.Id,
Type: model.PropertyFieldTypeText,
Name: "DMProp",
})
// Update DM property to same name as regular channel property - should succeed
// because DM channels have no team, so they don't conflict with team channels
dmField.Name = "ChannelProp"
result, _, err := th.service.UpdatePropertyField(rctx, groupID, dmField)
require.NoError(t, err)
assert.Equal(t, "ChannelProp", result.Name)
})
t.Run("updating name to conflicting value at system level should fail", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
team := th.CreateTeam(t)
// Create a system-level property
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "ExistingSystemProp",
})
// Create a team-level property with different name
teamField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelTeam),
TargetID: team.Id,
Type: model.PropertyFieldTypeText,
Name: "TeamProp",
})
// Try to update team-level to name that conflicts with system-level
teamField.Name = "ExistingSystemProp"
result, _, err := th.service.UpdatePropertyField(rctx, groupID, teamField)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "system-level")
})
t.Run("updating TargetType that creates conflict should fail", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
team := th.CreateTeam(t)
channel1 := th.CreateChannel(t, team.Id)
channel2 := th.CreateChannel(t, team.Id)
// Create two channel-level properties with the same name in different channels
// (no conflict since channel-level properties in different channels don't conflict)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel1.Id,
Type: model.PropertyFieldTypeText,
Name: "SharedName",
})
channel2Field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel2.Id,
Type: model.PropertyFieldTypeText,
Name: "SharedName",
})
// Try to update channel2's property to system-level - should conflict with channel1's property
channel2Field.TargetType = string(model.PropertyFieldTargetLevelSystem)
channel2Field.TargetID = ""
result, _, err := th.service.UpdatePropertyField(rctx, groupID, channel2Field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "channel-level")
})
t.Run("updating TargetID that creates conflict should fail", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
team := th.CreateTeam(t)
channel1 := th.CreateChannel(t, team.Id)
channel2 := th.CreateChannel(t, team.Id)
// Create two channel-level properties with the same name in different channels
// (no conflict since channel-level properties in different channels don't conflict)
th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel1.Id,
Type: model.PropertyFieldTypeText,
Name: "SharedName",
})
channel2Field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelChannel),
TargetID: channel2.Id,
Type: model.PropertyFieldTypeText,
Name: "SharedName",
})
// Update channel2's property TargetID to channel1 - should conflict
// because channel1 already has a property with the same name.
// Note: This conflict is caught by the database unique constraint, not the
// hierarchical conflict check (which only checks cross-level conflicts).
// We only verify an error occurs without checking the specific error type.
channel2Field.TargetID = channel1.Id
result, _, err := th.service.UpdatePropertyField(rctx, groupID, channel2Field)
require.Error(t, err)
assert.Nil(t, result)
})
t.Run("legacy property updates should skip conflict check", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
// Create a legacy property (no ObjectType)
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "", // Legacy
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "LegacyProp",
})
// Update name should succeed without conflict check
field.Name = "UpdatedLegacyProp"
result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "UpdatedLegacyProp", result.Name)
})
t.Run("property can be renamed to its own name", func(t *testing.T) {
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2).ID
// Create a property
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
ObjectType: "channel",
GroupID: groupID,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeText,
Name: "SameName",
})
// Update with same name should succeed (no actual change to name)
field.Attrs = map[string]any{"key": "changed"} // Change something else
result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "SameName", result.Name)
})
}
func TestLinkedPropertyFields(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
// Helper to create a source template field with select options
createSourceField := func(t *testing.T, name string) *model.PropertyField {
t.Helper()
return th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: name,
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": model.NewId(), "name": "Option A"},
map[string]any{"id": model.NewId(), "name": "Option B"},
},
},
})
}
t.Run("create linked field copies source type and options", func(t *testing.T) {
source := createSourceField(t, "Source-"+model.NewId())
linked, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText, // will be overwritten
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
require.NotNil(t, linked.LinkedFieldID)
assert.Equal(t, source.ID, *linked.LinkedFieldID)
assert.Equal(t, source.Type, linked.Type)
// Verify options were copied
sourceOpts := source.Attrs[model.PropertyFieldAttributeOptions]
linkedOpts := linked.Attrs[model.PropertyFieldAttributeOptions]
require.NotNil(t, linkedOpts)
assert.Equal(t, sourceOpts, linkedOpts)
})
t.Run("create linked field rejects non-existent source", func(t *testing.T) {
fakeID := model.NewId()
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &fakeID,
})
require.Error(t, err)
})
t.Run("create linked field rejects non-template source", func(t *testing.T) {
// Create a regular (non-template) field
regular := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: "RegularSource-" + model.NewId(),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": model.NewId(), "name": "Option A"},
},
},
})
// Try to link to the non-template field — should be rejected
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "LinkToRegular-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &regular.ID,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
})
t.Run("create linked field rejects chaining", func(t *testing.T) {
source := createSourceField(t, "ChainSource-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "ChainLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Try to link to the linked field (chain) — rejected because it's not a template
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "ChainAttempt-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &linked.ID,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
})
t.Run("create linked template field is rejected", func(t *testing.T) {
source := createSourceField(t, "TemplateLink-"+model.NewId())
// A template field should not itself be linked to another template
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "LinkedTemplate-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
})
t.Run("create linked field rejects target type mismatch", func(t *testing.T) {
source := createSourceField(t, "TTMismatchSource-"+model.NewId())
// Source has TargetType=system, try to link with TargetType=channel
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelChannel),
Name: "TTMismatch-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "target_type")
})
t.Run("update linked field blocks type change", func(t *testing.T) {
source := createSourceField(t, "TypeBlockSource-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "TypeBlockLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
linked.Type = model.PropertyFieldTypeText
_, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("update linked field blocks options change", func(t *testing.T) {
source := createSourceField(t, "OptsBlockSource-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "OptsBlockLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
linked.Attrs[model.PropertyFieldAttributeOptions] = []any{
map[string]any{"id": model.NewId(), "name": "Different"},
}
_, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("update linked field allows name change", func(t *testing.T) {
source := createSourceField(t, "NameChangeSource-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "NameChangeLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
linked.Name = "NewName-" + model.NewId()
result, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.NoError(t, err)
assert.Equal(t, linked.Name, result.Name)
})
t.Run("update source field propagates options to linked fields", func(t *testing.T) {
source := createSourceField(t, "PropagateSource-"+model.NewId())
linked1 := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "PropLinked1-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
linked2 := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "PropLinked2-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Update source options
newOptions := []any{
map[string]any{"id": model.NewId(), "name": "New Option 1"},
map[string]any{"id": model.NewId(), "name": "New Option 2"},
map[string]any{"id": model.NewId(), "name": "New Option 3"},
}
source.Attrs[model.PropertyFieldAttributeOptions] = newOptions
result, propagated, _, err := th.service.UpdatePropertyFields(rctx, group.ID, []*model.PropertyField{source})
require.NoError(t, err)
require.Len(t, result, 1) // only the requested source field
require.Len(t, propagated, 2) // 2 linked fields
// Verify linked fields got the new options
updatedLinked1, err := th.service.GetPropertyField(rctx, group.ID, linked1.ID)
require.NoError(t, err)
updatedLinked2, err := th.service.GetPropertyField(rctx, group.ID, linked2.ID)
require.NoError(t, err)
for _, linked := range []*model.PropertyField{updatedLinked1, updatedLinked2} {
opts := extractOptionIDList(linked.Attrs[model.PropertyFieldAttributeOptions])
expectedOpts := extractOptionIDList(newOptions)
assert.Equal(t, expectedOpts, opts)
}
})
t.Run("update source field blocks type change when dependents exist", func(t *testing.T) {
source := createSourceField(t, "TypeBlockDeps-"+model.NewId())
th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "DepLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
source.Type = model.PropertyFieldTypeMultiselect
_, _, err := th.service.UpdatePropertyField(rctx, group.ID, source)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
})
t.Run("delete source field blocked when linked dependents exist", func(t *testing.T) {
source := createSourceField(t, "DeleteBlock-"+model.NewId())
th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "DelDepLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
err := th.service.DeletePropertyField(rctx, group.ID, source.ID)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
})
t.Run("delete source field succeeds after dependents are deleted", func(t *testing.T) {
source := createSourceField(t, "DeleteOK-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "DeleteOKLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Delete the linked dependent first
err := th.service.DeletePropertyField(rctx, group.ID, linked.ID)
require.NoError(t, err)
// Now delete the source
err = th.service.DeletePropertyField(rctx, group.ID, source.ID)
require.NoError(t, err)
})
t.Run("unlink field preserves type and options", func(t *testing.T) {
source := createSourceField(t, "UnlinkSource-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "UnlinkLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Unlink by clearing LinkedFieldID
linked.LinkedFieldID = nil
result, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.NoError(t, err)
assert.Nil(t, result.LinkedFieldID)
assert.Equal(t, source.Type, result.Type)
// Verify options are preserved after unlinking
sourceOpts := extractOptionIDList(source.Attrs[model.PropertyFieldAttributeOptions])
resultOpts := extractOptionIDList(result.Attrs[model.PropertyFieldAttributeOptions])
require.NotEmpty(t, sourceOpts, "source should have options")
assert.Equal(t, sourceOpts, resultOpts, "options should be preserved after unlinking")
})
t.Run("template field value creation is rejected at service layer", func(t *testing.T) {
source := createSourceField(t, "TemplateValReject-"+model.NewId())
value := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
GroupID: group.ID,
FieldID: source.ID,
Value: json.RawMessage(`"some value"`),
}
_, err := th.service.CreatePropertyValue(rctx, value)
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
})
t.Run("template field value upsert is rejected at service layer", func(t *testing.T) {
source := createSourceField(t, "TemplateUpsertReject-"+model.NewId())
value := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
GroupID: group.ID,
FieldID: source.ID,
Value: json.RawMessage(`"some value"`),
}
_, err := th.service.UpsertPropertyValue(rctx, value)
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
})
t.Run("update blocks setting LinkedFieldID on non-linked field", func(t *testing.T) {
// Create a regular (non-linked) field
regular := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Regular-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
})
require.Nil(t, regular.LinkedFieldID)
// Attempt to set LinkedFieldID on update — should be rejected
source := createSourceField(t, "LinkAttemptSource-"+model.NewId())
regular.LinkedFieldID = &source.ID
_, _, err := th.service.UpdatePropertyField(rctx, group.ID, regular)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Error(), "creation time")
})
t.Run("update blocks changing LinkedFieldID to a different source", func(t *testing.T) {
source1 := createSourceField(t, "ChangeSource1-"+model.NewId())
source2 := createSourceField(t, "ChangeSource2-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "ChangeLink-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source1.ID,
})
// Attempt to change the link target — should be rejected
linked.LinkedFieldID = &source2.ID
_, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Error(), "cannot change link target")
})
t.Run("linked CPA field with LinkedFieldID behaves correctly", func(t *testing.T) {
source := createSourceField(t, "CPASource-"+model.NewId())
// Create a CPA-style linked field (user object type)
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "CPALinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Verify type and options were inherited
assert.Equal(t, source.Type, linked.Type)
assert.NotNil(t, linked.LinkedFieldID)
assert.Equal(t, source.ID, *linked.LinkedFieldID)
})
t.Run("CPA linked field delete succeeds", func(t *testing.T) {
source := createSourceField(t, "CPADelSource-"+model.NewId())
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "CPADelLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Deleting the linked field should succeed
err := th.service.DeletePropertyField(rctx, group.ID, linked.ID)
require.NoError(t, err)
})
t.Run("update source field propagates option removal to linked fields", func(t *testing.T) {
optAID := model.NewId()
optBID := model.NewId()
optCID := model.NewId()
source := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: "RemovalSource-" + model.NewId(),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": optAID, "name": "Option A", "color": "red"},
map[string]any{"id": optBID, "name": "Option B", "color": "blue"},
map[string]any{"id": optCID, "name": "Option C", "color": "green"},
},
},
})
linked := th.CreatePropertyField(t, rctx, &model.PropertyField{
GroupID: group.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "RemovalLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
// Remove option B, keep A and C
source.Attrs[model.PropertyFieldAttributeOptions] = []any{
map[string]any{"id": optAID, "name": "Option A", "color": "red"},
map[string]any{"id": optCID, "name": "Option C", "color": "green"},
}
result, propagated, _, err := th.service.UpdatePropertyFields(rctx, group.ID, []*model.PropertyField{source})
require.NoError(t, err)
require.Len(t, result, 1) // only the requested source field
require.Len(t, propagated, 1) // 1 linked field
// Verify the linked field has the updated options (B removed)
updatedLinked, err := th.service.GetPropertyField(rctx, group.ID, linked.ID)
require.NoError(t, err)
linkedOptIDs := extractOptionIDList(updatedLinked.Attrs[model.PropertyFieldAttributeOptions])
assert.Equal(t, []string{optAID, optCID}, linkedOptIDs, "option B should be removed from linked field")
// Verify option content (names, colors) was propagated correctly
linkedOpts := asOptionSlice(updatedLinked.Attrs)
require.Len(t, linkedOpts, 2)
assert.Equal(t, "Option A", linkedOpts[0]["name"])
assert.Equal(t, "red", linkedOpts[0]["color"])
assert.Equal(t, "Option C", linkedOpts[1]["name"])
assert.Equal(t, "green", linkedOpts[1]["color"])
})
t.Run("template field creation is rejected on v1 group", func(t *testing.T) {
v1Group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1)
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: v1Group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: "V1Template-" + model.NewId(),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": model.NewId(), "name": "Option A"},
},
},
})
require.Error(t, err)
})
t.Run("cross-group linking is rejected", func(t *testing.T) {
registeredA := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
registeredB := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
// Create a template in group A
source := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: registeredA.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: "CrossGroupSource-" + model.NewId(),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": model.NewId(), "name": "X"},
map[string]any{"id": model.NewId(), "name": "Y"},
},
},
})
// Linking from group B to a template in group A must fail
_, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: registeredB.ID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "CrossGroupLinked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "cross_group")
})
t.Run("update template value is rejected at service layer", func(t *testing.T) {
source := createSourceField(t, "TemplateUpdateReject-"+model.NewId())
// First create a value on a non-template field, then try to update
// using template field ID via UpdatePropertyValues
value := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
GroupID: group.ID,
FieldID: source.ID,
Value: json.RawMessage(`"some value"`),
}
_, err := th.service.UpdatePropertyValues(rctx, group.ID, []*model.PropertyValue{value})
require.Error(t, err)
assert.Contains(t, err.Error(), "template")
})
}
func TestOptionsChanged(t *testing.T) {
// attrsFromJSON simulates what arrives over the wire: JSON bytes
// deserialized into model.StringInterface, where options become
// []interface{} of map[string]interface{}.
attrsFromJSON := func(t *testing.T, jsonStr string) model.StringInterface {
t.Helper()
var attrs model.StringInterface
require.NoError(t, json.Unmarshal([]byte(jsonStr), &attrs))
return attrs
}
optID1 := model.NewId()
optID2 := model.NewId()
optID3 := model.NewId()
t.Run("both nil attrs means no change", func(t *testing.T) {
assert.False(t, optionsChanged(nil, nil))
})
t.Run("both empty attrs means no change", func(t *testing.T) {
assert.False(t, optionsChanged(model.StringInterface{}, model.StringInterface{}))
})
t.Run("nil vs empty attrs means no change", func(t *testing.T) {
assert.False(t, optionsChanged(nil, model.StringInterface{}))
assert.False(t, optionsChanged(model.StringInterface{}, nil))
})
t.Run("nil vs attrs with no options key means no change", func(t *testing.T) {
attrs := attrsFromJSON(t, `{"other_key": "value"}`)
assert.False(t, optionsChanged(nil, attrs))
assert.False(t, optionsChanged(attrs, nil))
})
t.Run("identical options means no change", func(t *testing.T) {
raw := `{"options": [{"id": "` + optID1 + `", "name": "A"}, {"id": "` + optID2 + `", "name": "B"}]}`
old := attrsFromJSON(t, raw)
updated := attrsFromJSON(t, raw)
assert.False(t, optionsChanged(old, updated))
})
t.Run("different option count is a change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}, {"id": "`+optID2+`", "name": "B"}]}`)
assert.True(t, optionsChanged(old, updated))
assert.True(t, optionsChanged(updated, old))
})
t.Run("option replaced with different ID (same count) is a change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}, {"id": "`+optID2+`", "name": "B"}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}, {"id": "`+optID3+`", "name": "C"}]}`)
assert.True(t, optionsChanged(old, updated))
})
t.Run("option name renamed is a change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A-renamed"}]}`)
assert.True(t, optionsChanged(old, updated))
})
t.Run("extra key added to option is a change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "color": "red"}]}`)
assert.True(t, optionsChanged(old, updated))
})
t.Run("extra key removed from option is a change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "color": "red"}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
assert.True(t, optionsChanged(old, updated))
})
t.Run("reordered options with same IDs means no change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}, {"id": "`+optID2+`", "name": "B"}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID2+`", "name": "B"}, {"id": "`+optID1+`", "name": "A"}]}`)
assert.False(t, optionsChanged(old, updated))
})
t.Run("no options vs options present is a change", func(t *testing.T) {
old := model.StringInterface{}
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
assert.True(t, optionsChanged(old, updated))
assert.True(t, optionsChanged(updated, old))
})
t.Run("options null in JSON vs absent means no change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": null}`)
updated := model.StringInterface{}
assert.False(t, optionsChanged(old, updated))
assert.False(t, optionsChanged(updated, old))
})
t.Run("empty options array vs absent is a change", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": []}`)
updated := model.StringInterface{}
// []any{} has len 0, nil has len 0 — asOptionSlice returns empty vs nil
// but len check treats both as 0, so no change detected
assert.False(t, optionsChanged(old, updated))
})
t.Run("non-map items in options array are skipped", func(t *testing.T) {
// After JSON unmarshal, options are always maps. But if somehow
// a non-map sneaks in, asOptionSlice drops it — which changes
// the effective count.
old := model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": optID1, "name": "A"},
"not a map",
},
}
updated := model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": optID1, "name": "A"},
},
}
// old becomes 1 valid option (non-map dropped), new has 1 — same
assert.False(t, optionsChanged(old, updated))
})
t.Run("options value is not a slice means no change vs absent", func(t *testing.T) {
old := model.StringInterface{
model.PropertyFieldAttributeOptions: "not a slice",
}
assert.False(t, optionsChanged(old, nil))
})
t.Run("options value is not a slice vs real options is a change", func(t *testing.T) {
old := model.StringInterface{
model.PropertyFieldAttributeOptions: "not a slice",
}
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
assert.True(t, optionsChanged(old, updated))
})
t.Run("other attrs keys are ignored", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}], "color": "red"}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}], "color": "blue"}`)
assert.False(t, optionsChanged(old, updated))
})
t.Run("numeric value in option survives JSON round-trip", func(t *testing.T) {
// JSON numbers deserialize as float64 — verify DeepEqual handles this
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "sort_order": 1}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "sort_order": 1}]}`)
assert.False(t, optionsChanged(old, updated))
changed := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "sort_order": 2}]}`)
assert.True(t, optionsChanged(old, changed))
})
t.Run("boolean value in option survives JSON round-trip", func(t *testing.T) {
old := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "disabled": false}]}`)
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "disabled": true}]}`)
assert.True(t, optionsChanged(old, updated))
})
t.Run("non-[]any option slice returns nil (treated as no options)", func(t *testing.T) {
// Producers in this codebase normalize attrs["options"] to []any of
// map[string]any (see EnsureOptionIDs, AccessControlAttributeValidationHook.
// sanitizeAndValidateOptions). If a non-canonical shape ever sneaks in,
// asOptionSlice returns nil, which makes optionsChanged report "changed"
// against any populated side — the safe failure mode that surfaces the
// contract violation instead of silently passing.
dbForm := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
nonCanonical := model.StringInterface{
model.PropertyFieldAttributeOptions: model.PropertyOptions[*model.CustomProfileAttributesSelectOption]{
{ID: optID1, Name: "A"},
},
}
assert.True(t, optionsChanged(dbForm, nonCanonical))
})
}